diff --git a/config/relay_pro/url_config.ch_1774436412.ini b/config/relay_pro/url_config.ch_1774436412.ini index 5bef370..8491383 100644 --- a/config/relay_pro/url_config.ch_1774436412.ini +++ b/config/relay_pro/url_config.ch_1774436412.ini @@ -1 +1 @@ -https://live.douyin.com/11664523242 \ No newline at end of file +https://live.douyin.com/11664523242 \ No newline at end of file diff --git a/config/relay_pro/url_config.ch_1779328176.ini b/config/relay_pro/url_config.ch_1779328176.ini new file mode 100644 index 0000000..66df736 --- /dev/null +++ b/config/relay_pro/url_config.ch_1779328176.ini @@ -0,0 +1 @@ +124125425235 \ No newline at end of file diff --git a/config/youtube_channels.json b/config/youtube_channels.json index 9ead28d..8ceece8 100644 --- a/config/youtube_channels.json +++ b/config/youtube_channels.json @@ -6,7 +6,10 @@ "id": "ch_1774436412", "name": "频道 2", "douyinUrl": "", - "youtubeKey": "112313123124124124124124124df" + "youtubeKey": "112313123124124124124124124df", + "youtubeKeys": [ + "112313123124124124124124124df" + ] }, { "id": "ch_1774436414", @@ -97,6 +100,15 @@ "name": "频道 18", "douyinUrl": "", "youtubeKey": "" + }, + { + "id": "ch_1779328176", + "name": "频道 17", + "douyinUrl": "", + "youtubeKey": "qwr233232352354", + "youtubeKeys": [ + "qwr233232352354" + ] } ] } \ No newline at end of file diff --git a/electron/src/main/appHost.ts b/electron/src/main/appHost.ts index e69c973..820e92d 100644 --- a/electron/src/main/appHost.ts +++ b/electron/src/main/appHost.ts @@ -110,6 +110,24 @@ export function applyAppHostBootstrap(getBackendRoot: () => string): void { } export function registerAppHostIpcHandlers(getBackendRoot: () => string): void { + ipcMain.removeHandler('app:get-info') + ipcMain.handle('app:get-info', () => { + return { + name: app.getName(), + version: app.getVersion(), + locale: app.getLocale(), + systemLocale: app.getSystemLocale(), + isPackaged: app.isPackaged, + appPath: app.getAppPath(), + executablePath: process.execPath, + platform: process.platform, + arch: process.arch, + chrome: process.versions.chrome, + electron: process.versions.electron, + node: process.versions.node, + } + }) + ipcMain.removeHandler('app:get-login-item') ipcMain.handle('app:get-login-item', () => { try { @@ -182,6 +200,30 @@ export function registerAppHostIpcHandlers(getBackendRoot: () => string): void { } }) + ipcMain.removeHandler('app:open-path') + ipcMain.handle('app:open-path', async (_evt, key: unknown) => { + const br = getBackendRoot() + const paths: Record = { + userData: app.getPath('userData'), + logs: app.getPath('logs'), + temp: app.getPath('temp'), + backendRoot: br, + backendConfig: path.join(br, 'config'), + downloads: path.join(br, 'downloads'), + } + if (typeof key !== 'string' || !(key in paths)) { + return { ok: false as const, error: '路径参数无效' } + } + const target = paths[key] + try { + await fs.promises.mkdir(target, { recursive: true }) + } catch { + /* 文件夹可能只读或已存在 */ + } + const err = await shell.openPath(target) + return err ? { ok: false as const, error: err } : { ok: true as const } + }) + ipcMain.removeHandler('app:add-recent-config') ipcMain.handle('app:add-recent-config', () => { const cfg = path.join(getBackendRoot(), 'config') @@ -205,6 +247,26 @@ export function registerAppHostIpcHandlers(getBackendRoot: () => string): void { const err = await shell.openPath(p) if (err) throw new Error(err) }) + + ipcMain.removeHandler('app:open-system-settings') + ipcMain.handle('app:open-system-settings', async (_evt, page: unknown) => { + if (process.platform !== 'win32') return { ok: false as const, error: '仅 Windows 支持' } + const map: Record = { + proxy: 'ms-settings:network-proxy', + startup: 'ms-settings:startupapps', + display: 'ms-settings:display', + apps: 'ms-settings:appsfeatures', + } + const key = typeof page === 'string' ? page : '' + const url = map[key] + if (!url) return { ok: false as const, error: '设置页面参数无效' } + try { + await shell.openExternal(url) + return { ok: true as const } + } catch (e) { + return { ok: false as const, error: e instanceof Error ? e.message : String(e) } + } + }) } export function sendNavToWindow(win: BrowserWindow, nav: string): void { diff --git a/electron/src/main/index.ts b/electron/src/main/index.ts index 50fae2b..bf97d24 100644 --- a/electron/src/main/index.ts +++ b/electron/src/main/index.ts @@ -571,12 +571,7 @@ if (!gotLock) { }) app.on('second-instance', (_evt, commandLine) => { - const argv = Array.isArray(commandLine) - ? commandLine - : typeof commandLine === 'string' - ? commandLine.split(/\s+/).filter(Boolean) - : [String(commandLine)] - const nav = parseNavFromArgv(argv) + const nav = parseNavFromArgv(commandLine) if (mainWindow) { if (mainWindow.isMinimized()) mainWindow.restore() mainWindow.show() @@ -690,6 +685,9 @@ if (!gotLock) { ipcMain.removeHandler('web2:get-api-token') ipcMain.handle('web2:get-api-token', () => resolveWeb2ApiTokenForChild() ?? null) + ipcMain.removeHandler('web2:get-api-base') + ipcMain.handle('web2:get-api-base', () => `http://${healthCheckHost()}:${PORT}`) + ipcMain.removeHandler('remote:get-status') ipcMain.handle('remote:get-status', () => { const bindHost = resolveWeb2Host() @@ -708,6 +706,8 @@ if (!gotLock) { if (typeof selectedIp !== 'string' || !selectedIp.trim()) return null const token = resolveWeb2ApiTokenForChild() ?? '' const ip = selectedIp.trim() + const candidates = collectLanCandidateIPs() + if (!candidates.some((c) => c.address === ip)) return null return { v: 1 as const, baseUrl: `http://${ip}:${PORT}`, diff --git a/electron/src/preload/index.ts b/electron/src/preload/index.ts index e16f355..86191d7 100644 --- a/electron/src/preload/index.ts +++ b/electron/src/preload/index.ts @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('recorderConsole', { }) contextBridge.exposeInMainWorld('web2Remote', { + getApiBase: (): Promise => ipcRenderer.invoke('web2:get-api-base'), getApiToken: (): Promise => ipcRenderer.invoke('web2:get-api-token'), }) @@ -62,6 +63,7 @@ contextBridge.exposeInMainWorld('appHost', { ipcRenderer.on('app:nav', listener) return () => ipcRenderer.removeListener('app:nav', listener) }, + getInfo: () => ipcRenderer.invoke('app:get-info'), getLoginItemSettings: () => ipcRenderer.invoke('app:get-login-item'), setLoginItemOpenAtLogin: (v: boolean) => ipcRenderer.invoke('app:set-login-item', v), relaunch: () => ipcRenderer.invoke('app:relaunch'), @@ -69,6 +71,8 @@ contextBridge.exposeInMainWorld('appHost', { showAbout: () => ipcRenderer.invoke('app:show-about'), setBadge: (n: number) => ipcRenderer.invoke('app:set-badge', n), getPaths: () => ipcRenderer.invoke('app:get-paths'), + openPath: (key: string) => ipcRenderer.invoke('app:open-path', key), addRecentConfig: () => ipcRenderer.invoke('app:add-recent-config'), openLogsFolder: () => ipcRenderer.invoke('app:open-logs-folder'), + openSystemSettings: (page: string) => ipcRenderer.invoke('app:open-system-settings', page), }) diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index 99d3133..44b5f91 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1113,6 +1113,253 @@ body.ready { box-shadow: var(--shadow-md), 0 0 0 1px rgba(91, 157, 255, 0.06); } +.card--live-command { + position: relative; + overflow: hidden; + border-color: rgba(61, 214, 140, 0.2); + background: + radial-gradient(circle at 12% 0%, rgba(61, 214, 140, 0.14), transparent 34%), + radial-gradient(circle at 88% 18%, rgba(91, 157, 255, 0.16), transparent 34%), + linear-gradient(145deg, rgba(34, 38, 46, 0.96), var(--bg-card)); +} + +:root[data-theme="light"] .card--live-command { + background: + radial-gradient(circle at 12% 0%, rgba(5, 150, 105, 0.14), transparent 34%), + radial-gradient(circle at 88% 18%, rgba(37, 99, 235, 0.14), transparent 34%), + linear-gradient(145deg, #ffffff, #f8fafc); +} + +.live-command__top { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} + +.live-command__eyebrow { + display: inline-flex; + margin-bottom: 6px; + font-size: 11px; + font-weight: 750; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--success); +} + +.live-command__title { + margin: 0; + font-size: 22px; + line-height: 1.18; + letter-spacing: -0.04em; + color: var(--text); +} + +.live-command__desc { + max-width: 680px; + margin: 8px 0 0; + font-size: 13px; + color: var(--text-muted); +} + +.live-command__badge { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 7px 12px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 750; + border: 1px solid var(--border-card); + background: var(--bg-card-muted); +} + +.live-command__badge--ok { + color: var(--success); + border-color: rgba(61, 214, 140, 0.35); + background: var(--success-soft); +} + +.live-command__badge--warn { + color: var(--warn); + border-color: rgba(246, 173, 85, 0.35); + background: var(--warn-soft); +} + +.live-command__quick-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + align-items: center; + gap: 8px; + max-width: min(100%, 520px); +} + +.live-command__stats { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 10px; + margin-bottom: 16px; +} + +.live-command__stat { + min-width: 0; + padding: 10px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.035); +} + +:root[data-theme="light"] .live-command__stat { + background: rgba(15, 23, 42, 0.035); +} + +.live-command__stat span { + display: block; + margin-bottom: 3px; + font-size: 11px; + color: var(--text-muted); +} + +.live-command__stat strong { + display: block; + overflow: hidden; + font-size: 13px; + font-weight: 750; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-pipeline { + display: grid; + grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr); + align-items: center; + gap: 10px; +} + +.live-pipeline__step { + min-height: 70px; + padding: 13px 14px; + border-radius: 16px; + color: #fff; + box-shadow: var(--shadow-sm); +} + +.live-pipeline__step span { + display: block; + margin-bottom: 3px; + font-size: 11px; + font-weight: 650; + opacity: 0.78; +} + +.live-pipeline__step strong { + display: block; + font-size: 14px; + font-weight: 800; +} + +.live-pipeline__step--source { + background: linear-gradient(145deg, #0e8f6e, #0f5f57); +} + +.live-pipeline__step--encode { + background: linear-gradient(145deg, #2f6eea, #27447d); +} + +.live-pipeline__step--youtube { + background: linear-gradient(145deg, #f04438, #9f1f19); +} + +.live-pipeline__arrow { + color: var(--text-muted); + font-size: 28px; + line-height: 1; +} + +.live-preflight-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +.live-preflight-item { + display: flex; + align-items: flex-start; + gap: 9px; + min-width: 0; + min-height: 62px; + padding: 10px 11px; + border-radius: var(--radius-md); + border: 1px solid var(--border-subtle); + background: rgba(255, 255, 255, 0.032); +} + +:root[data-theme="light"] .live-preflight-item { + background: rgba(15, 23, 42, 0.028); +} + +.live-preflight-item__dot { + width: 8px; + height: 8px; + margin-top: 6px; + border-radius: var(--radius-pill); + flex-shrink: 0; + background: var(--warn); + box-shadow: 0 0 0 4px var(--warn-soft); +} + +.live-preflight-item.is-ok .live-preflight-item__dot { + background: var(--success); + box-shadow: 0 0 0 4px var(--success-soft); +} + +.live-preflight-item__body { + min-width: 0; +} + +.live-preflight-item__body strong, +.live-preflight-item__body span { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.live-preflight-item__body strong { + font-size: 12px; + font-weight: 750; + color: var(--text); +} + +.live-preflight-item__body span { + margin-top: 2px; + font-size: 11px; + color: var(--text-muted); +} + +@media (max-width: 860px) { + .live-command__stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .live-pipeline { + grid-template-columns: 1fr; + } + + .live-pipeline__arrow { + display: none; + } + + .live-preflight-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .card--log .card__desc { margin-bottom: 12px; } @@ -1458,6 +1705,10 @@ body.ready { background: var(--bg-input); border: 1px solid var(--border-strong); cursor: pointer; + transition: + border-color 0.18s var(--ease), + background 0.18s var(--ease), + box-shadow 0.18s var(--ease); } .pro-live-switch-row.is-selected { @@ -1465,6 +1716,15 @@ body.ready { box-shadow: 0 0 0 1px rgba(91, 157, 255, 0.22); } +.pro-live-switch-row.is-ready { + border-color: rgba(61, 214, 140, 0.2); +} + +.pro-live-switch-row.is-misconfigured { + border-color: rgba(246, 173, 85, 0.26); + background: linear-gradient(180deg, rgba(246, 173, 85, 0.06), var(--bg-input)); +} + @media (max-width: 900px) { .pro-live-switch-row { grid-template-columns: 1fr; @@ -1517,6 +1777,16 @@ body.ready { font-size: 13px; } +.pro-live-switch-sub { + display: block; + margin-top: 3px; + overflow: hidden; + font-size: 11px; + color: var(--text-muted); + text-overflow: ellipsis; + white-space: nowrap; +} + .pro-live-switch-actions { display: flex; flex-wrap: wrap; @@ -1835,6 +2105,51 @@ body.ready { word-break: break-word; } +.live-log-detail__v--mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.live-business-pill { + display: inline-flex; + width: fit-content; + max-width: 100%; + align-items: center; + min-height: 24px; + padding: 3px 9px; + border-radius: var(--radius-pill); + border: 1px solid var(--border-card); + color: var(--text-muted); + background: var(--bg-card-muted); +} + +.live-business-pill--streaming { + color: var(--success); + border-color: rgba(61, 214, 140, 0.35); + background: var(--success-soft); +} + +.live-business-pill--waiting_source, +.live-business-pill--source_live, +.live-business-pill--stale { + color: var(--warn); + border-color: rgba(246, 173, 85, 0.36); + background: var(--warn-soft); +} + +.live-business-pill--misconfigured, +.live-business-pill--error { + color: var(--danger); + border-color: rgba(245, 101, 101, 0.36); + background: var(--danger-soft); +} + +.live-business-pill--monitoring { + color: var(--accent-2); + border-color: rgba(91, 157, 255, 0.35); + background: var(--accent-dim); +} + .live-log-detail__row--top { align-items: start; } @@ -3016,22 +3331,103 @@ button.net-badge { font-weight: 600; } +.metrics-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.metrics-uptime { + display: inline-flex; + padding: 5px 10px; + border-radius: var(--radius-pill); + font-size: 12px; + font-weight: 650; + color: var(--accent-2); + background: var(--accent-dim); + border: 1px solid rgba(91, 157, 255, 0.25); +} + +.metrics-health { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0 0 12px; +} + +.metrics-health span { + padding: 5px 9px; + border-radius: var(--radius-pill); + color: var(--warn); + background: var(--warn-soft); + border: 1px solid rgba(246, 173, 85, 0.3); + font-size: 12px; +} + /* Electron app 模块:系统页「客户端与系统集成」 */ .app-host-card { margin-top: 1rem; } +.app-host-head { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.app-host-version { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + min-width: 90px; +} + +.app-host-version span { + color: var(--text-muted); + font-size: 11px; +} + +.app-host-version strong { + color: var(--accent-2); + font-size: 16px; +} + .app-host-grid { display: flex; flex-direction: column; gap: 0.25rem; } +.app-host-grid--cards { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 12px; +} + +@media (max-width: 860px) { + .app-host-grid--cards { + grid-template-columns: 1fr; + } +} + .app-host-block { padding: 0.65rem 0; border-top: 1px solid var(--border-subtle); } +.app-host-grid--cards .app-host-block { + padding: 12px 14px; + border: 1px solid var(--border-subtle); + border-radius: var(--radius-md); + background: var(--bg-card-elevated); +} + .app-host-block:first-of-type { border-top: none; } @@ -3066,6 +3462,40 @@ button.net-badge { gap: 0.5rem; } +.app-host-kpis { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.app-host-kpis span { + min-width: 0; + padding: 9px 8px; + border-radius: var(--radius-md); + background: var(--bg-input); + border: 1px solid var(--border-subtle); +} + +.app-host-kpis strong, +.app-host-kpis em { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-host-kpis strong { + color: var(--text); + font-size: 14px; +} + +.app-host-kpis em { + margin-top: 2px; + color: var(--text-muted); + font-style: normal; + font-size: 10px; +} + .app-host-hint { margin: 0.45rem 0 0; font-size: 11px; @@ -3099,6 +3529,10 @@ button.net-badge { } .app-host-paths li { + display: grid; + grid-template-columns: 6rem minmax(0, 1fr) auto; + gap: 8px; + align-items: baseline; margin-bottom: 0.4rem; word-break: break-all; } @@ -3115,6 +3549,17 @@ button.net-badge { color: var(--text); } +.app-host-paths__open { + white-space: nowrap; + font-size: 11px; +} + +@media (max-width: 720px) { + .app-host-paths li { + grid-template-columns: 1fr; + } +} + .metrics-table-wrap { overflow: auto; border-radius: var(--radius-md); @@ -3142,6 +3587,16 @@ button.net-badge { opacity: 0.9; } +.remote-success { + margin: 0 0 12px; + padding: 10px 12px; + font-size: 13px; + color: var(--success); + background: var(--success-soft); + border: 1px solid rgba(61, 214, 140, 0.35); + border-radius: var(--radius-md); +} + /* 统一弹窗:用户协议 / 授权 */ .modal-backdrop { position: fixed; diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 544637a..71ccc5f 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -92,6 +92,13 @@ function douyinUrlListDisplayFromServer(content: string): string { return hasReal ? String(content) : '' } +function countConfigUrlLines(content: string): number { + return String(content ?? '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')).length +} + type DouyinPreviewRow = { ok?: boolean url?: string @@ -134,6 +141,24 @@ function formatDurationCn(totalSec: number): string { return `${h} 小时 ${mm} 分` } +function formatRuntimeSeconds(sec: number | null | undefined): string { + if (typeof sec !== 'number' || !Number.isFinite(sec)) return '—' + return formatDurationCn(Math.max(0, Math.floor(sec))) +} + +function businessStatusLabel(raw?: string, note?: string): string { + const s = (raw || '').trim().toLowerCase() + if (s === 'streaming') return '正在推流到 YouTube' + if (s === 'waiting_source') return '等待源站开播' + if (s === 'source_live') return '源站已开播,等待 FFmpeg 推流' + if (s === 'monitoring') return '进程在线,正在监测源站' + if (s === 'misconfigured') return '配置异常' + if (s === 'stale') return '日志心跳过期' + if (s === 'error') return '近期有异常' + if (s === 'stopped') return '未运行' + return note?.trim() || '暂无业务态' +} + type NavId = 'desk' | 'record' | 'network' | 'remote' | 'system' | 'wechat' type AppTheme = 'light' | 'dark' @@ -362,6 +387,23 @@ function statusPresentation(processStatus: string): { line: string; variant: Sta return { line: '状态未知', variant: 'unknown' } } +async function readControlResponse(res: Response): Promise> { + let data: Record = {} + try { + const parsed = (await res.json()) as unknown + if (parsed && typeof parsed === 'object') data = parsed as Record + } catch { + data = {} + } + if (!res.ok) { + const msg = [data.output, data.error, data.message].find( + (x): x is string => typeof x === 'string' && x.trim().length > 0, + ) + throw new Error(msg || `HTTP ${res.status}`) + } + return data +} + export default function App() { const [theme, setTheme] = useState(readInitialTheme) @@ -389,6 +431,11 @@ export default function App() { processStatus: string recentLog: string recentError: string + businessStatus?: string + businessNote?: string + ffmpegPids?: number[] + logAgeSeconds?: number | null + isPushing?: boolean }>({ processStatus: 'unknown', recentLog: '', recentError: '' }) const [liveStartedAtMs, setLiveStartedAtMs] = useState(null) const [controlNotice, setControlNotice] = useState('') @@ -414,7 +461,8 @@ export default function App() { const [saveUrlLoadingId, setSaveUrlLoadingId] = useState(null) const [highlightedChannelId, setHighlightedChannelId] = useState('') const highlightedChannelIdRef = useRef('') - /** 已保存到服务端的密钥列表(决定直播开关行数);本地草稿多出的行不会出现在开关区 */ + const proUrlLoadedChannelIdsRef = useRef>(new Set()) + /** 已保存到服务端的密钥列表;空白占位频道不作为可直播线路展示 */ const [committedChannelKeys, setCommittedChannelKeys] = useState>({}) const [addChannelBusy, setAddChannelBusy] = useState(false) /** 点击「新增频道」后出现的草稿行:填写密钥后点保存再真正创建频道 */ @@ -743,10 +791,39 @@ export default function App() { [proChannelEditors], ) + const loadProUrlTextFor = useCallback( + async (channelId: string, force = false): Promise => { + if (!force && proUrlLoadedChannelIdsRef.current.has(channelId)) { + return proChannelEditors[channelId]?.urlText ?? '' + } + try { + const res = await apiFetch( + apiUrl(`/relay_pro/url_config?channel_id=${encodeURIComponent(channelId)}`), + ) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = (await res.json()) as { content?: string } + const content = douyinUrlListDisplayFromServer(String(data.content ?? '')) + proUrlLoadedChannelIdsRef.current.add(channelId) + setProChannelEditors((prev) => { + const cur = prev[channelId] + if (cur) return { ...prev, [channelId]: { ...cur, urlText: content } } + return { ...prev, [channelId]: { keys: [''], activeIndex: 0, urlText: content } } + }) + setUrlStatus('已读取') + return content + } catch (err) { + setUrlStatus(`读取失败:${err instanceof Error ? err.message : String(err)}`) + return null + } + }, + [proChannelEditors], + ) + const saveProUrlFor = useCallback( - async (channelId: string) => { + async (channelId: string, contentOverride?: string): Promise => { const ed = proChannelEditors[channelId] - if (!ed) return + if (!ed) return false + const content = contentOverride ?? ed.urlText setSaveUrlLoadingId(channelId) try { const res = await apiFetch( @@ -754,15 +831,18 @@ export default function App() { { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content: ed.urlText }), + body: JSON.stringify({ content }), }, ) const data = (await res.json()) as { error?: string; message?: string } if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`) + proUrlLoadedChannelIdsRef.current.add(channelId) setUrlStatus(data.message || '已保存') void window.relayConfig?.importFromConfig?.() + return true } catch (err) { setUrlStatus(`保存失败:${err instanceof Error ? err.message : String(err)}`) + return false } finally { setSaveUrlLoadingId(null) } @@ -872,8 +952,7 @@ export default function App() { } try { const res = await apiFetch(`${apiUrl(`/${action}`)}?process=${encodeURIComponent(proc)}`) - if (!res.ok) throw new Error('无法连接到本机服务') - const data = await res.json() + const data = await readControlResponse(res) if (action === 'status') { const ps = data.process_status as string @@ -910,7 +989,20 @@ export default function App() { } else { const rl = String(data.recent_log ?? '') const re = String(data.recent_error ?? '') - setLiveDetail({ processStatus: ps, recentLog: rl, recentError: re }) + const pids = Array.isArray(data.ffmpeg_pids) + ? data.ffmpeg_pids.map((x) => Number(x)).filter((x) => Number.isFinite(x)) + : [] + const ageRaw = data.log_age_seconds + setLiveDetail({ + processStatus: ps, + recentLog: rl, + recentError: re, + businessStatus: typeof data.business_status === 'string' ? data.business_status : '', + businessNote: typeof data.business_note === 'string' ? data.business_note : '', + ffmpegPids: pids, + logAgeSeconds: typeof ageRaw === 'number' && Number.isFinite(ageRaw) ? ageRaw : null, + isPushing: data.is_pushing === true, + }) } } else { const e = getEntry(proc) @@ -1165,6 +1257,7 @@ export default function App() { useEffect(() => { if (!multiChannelPro) { highlightedChannelIdRef.current = '' + proUrlLoadedChannelIdsRef.current.clear() setHighlightedChannelId('') setCommittedChannelKeys({}) } @@ -1180,9 +1273,20 @@ export default function App() { try { const saved = await saveProChannelKeysFor(channelId) if (!saved) return + const edBeforeUrlSave = proChannelEditors[channelId] + let urlOverride: string | undefined + if ( + !proUrlLoadedChannelIdsRef.current.has(channelId) && + countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0 + ) { + const loadedUrl = await loadProUrlTextFor(channelId, true) + if (loadedUrl === null) return + urlOverride = loadedUrl + } + const savedUrl = await saveProUrlFor(channelId, urlOverride) + if (!savedUrl) return const res = await apiFetch(`${apiUrl('/start')}?process=${encodeURIComponent(pm)}`) - if (!res.ok) throw new Error('无法连接到本机服务') - const data = await res.json() + const data = await readControlResponse(res) const e = getEntry(pm) const who = e ? e.label : '当前任务' @@ -1222,7 +1326,7 @@ export default function App() { setLoadingAction(null) } }, - [getEntry, runPm2Action, proChannelEditors, saveProChannelKeysFor, ensureCanControlLive], + [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelKeysFor, saveProUrlFor, ensureCanControlLive], ) const runProChannelRestart = useCallback( @@ -1235,9 +1339,20 @@ export default function App() { try { const saved = await saveProChannelKeysFor(channelId) if (!saved) return + const edBeforeUrlSave = proChannelEditors[channelId] + let urlOverride: string | undefined + if ( + !proUrlLoadedChannelIdsRef.current.has(channelId) && + countConfigUrlLines(edBeforeUrlSave?.urlText ?? '') === 0 + ) { + const loadedUrl = await loadProUrlTextFor(channelId, true) + if (loadedUrl === null) return + urlOverride = loadedUrl + } + const savedUrl = await saveProUrlFor(channelId, urlOverride) + if (!savedUrl) return const res = await apiFetch(`${apiUrl('/restart')}?process=${encodeURIComponent(pm)}`) - if (!res.ok) throw new Error('无法连接到本机服务') - const data = await res.json() + const data = await readControlResponse(res) const e = getEntry(pm) const who = e ? e.label : '当前任务' @@ -1261,7 +1376,7 @@ export default function App() { setLoadingAction(null) } }, - [getEntry, runPm2Action, saveProChannelKeysFor, ensureCanControlLive], + [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelKeysFor, saveProUrlFor, ensureCanControlLive], ) useEffect(() => { @@ -1350,6 +1465,7 @@ export default function App() { const j = (await ur.json()) as { content?: string } const content = douyinUrlListDisplayFromServer(String(j.content ?? '')) if (cid !== highlightedChannelIdRef.current) return + proUrlLoadedChannelIdsRef.current.add(cid) setProChannelEditors((prev) => { const cur = prev[cid] if (cur) { @@ -1427,6 +1543,13 @@ export default function App() { return parseYoutubeStreamKeySuffix(`key = ${youtubeIniActiveKey(ytIniModel)}`) }, [isYoutube, multiChannelPro, highlightedChannelId, committedChannelKeys, proChannelEditors, ytIniModel]) + const openYoutubeStudio = useCallback(() => { + const suffix = keySuffixForUi || undefined + const ensure = window.deskBridge?.ensureYoutubeStudio + if (ensure) void ensure(suffix) + else window.open('https://studio.youtube.com/livestreaming', '_blank', 'noopener,noreferrer') + }, [keySuffixForUi]) + const relayPs = liveDetail.processStatus const isRelayProcessLive = relayPs === 'online' const pmBusy = loadingAction !== null @@ -1442,21 +1565,47 @@ export default function App() { channelName: string douyinUrl: string live: boolean + keyConfigured: boolean + keyText: string + keyDisplay: string + sourceCount: number + ready: boolean + readyReason: string }[] = [] for (const c of proChannelsList) { const saved = committedChannelKeys[c.id] - if (!saved?.length) continue + const ed = proChannelEditors[c.id] + const keyText = ((saved?.[0] ?? ed?.keys?.[0] ?? '') || '').trim() + const sourceText = proUrlLoadedChannelIdsRef.current.has(c.id) + ? ed?.urlText ?? '' + : ed?.urlText || c.douyinUrl || '' + const sourceCount = countConfigUrlLines(sourceText) + const keyConfigured = !!keyText + const live = liveMap.get(c.id) ?? false + const isCurrentEditor = c.id === highlightedChannelId + if (!keyConfigured && sourceCount <= 0 && !live && !isCurrentEditor) continue + const readyReason = !keyConfigured + ? '缺少 YouTube 串流密钥' + : sourceCount <= 0 + ? '缺少源站直播地址' + : '预检通过' rows.push({ channelId: c.id, channelName: c.name, douyinUrl: c.douyinUrl ?? '', - live: liveMap.get(c.id) ?? false, + live, + keyConfigured, + keyText, + keyDisplay: parseYoutubeStreamKeySuffix(`key = ${keyText}`) || c.keyPreview || '未配置', + sourceCount, + ready: keyConfigured && sourceCount > 0, + readyReason, }) } return rows - }, [proChannelsList, proRelayStatus, committedChannelKeys]) + }, [proChannelsList, proRelayStatus, committedChannelKeys, proChannelEditors, highlightedChannelId]) - /** 多频道下若尚未选中线路,默认高亮直播开关第一行 */ + /** 多频道下若尚未选中线路,默认高亮第一条频道,未配置频道也能直接进入编辑 */ useEffect(() => { if (!isYoutube || !multiChannelPro) return if (highlightedChannelId) return @@ -1471,6 +1620,99 @@ export default function App() { return formatDurationCn(Math.floor((Date.now() - liveStartedAtMs) / 1000)) }, [liveStartedAtMs, liveTick]) + const singleSourceCount = useMemo(() => countConfigUrlLines(urlText), [urlText]) + const singleKeyConfigured = !!youtubeIniActiveKey(ytIniModel) + const proConfiguredLineCount = proLiveRows.filter((r) => r.keyConfigured || r.sourceCount > 0 || r.live).length + const proVisibleLineCount = proLiveRows.length + const proLiveCount = proLiveRows.filter((r) => r.live).length + const proReadyCount = proLiveRows.filter((r) => r.ready).length + const proKeyCount = proLiveRows.filter((r) => r.keyConfigured).length + const proSourceCount = proLiveRows.reduce((sum, r) => sum + r.sourceCount, 0) + const selectedProRow = proLiveRows.find((r) => r.channelId === highlightedChannelId) + const liveReady = multiChannelPro + ? proReadyCount > 0 + : singleKeyConfigured && singleSourceCount > 0 + const livePreflightText = multiChannelPro + ? proVisibleLineCount === 0 + ? '还没有 Pro 频道' + : `${proReadyCount}/${proVisibleLineCount} 条线路可启动` + : liveReady + ? '单频道预检通过' + : !singleKeyConfigured + ? '缺少 YouTube 串流密钥' + : '缺少源站直播地址' + const gpuStatusText = gpuCaps === null + ? '检测中' + : gpuCaps.nvenc_available + ? 'NVENC 可用' + : gpuCaps.nvidia_detected + ? '已检测 NVIDIA,NVENC 不可用' + : '未检测到 NVIDIA/NVENC' + const liveBusinessLabel = businessStatusLabel(liveDetail.businessStatus, liveDetail.businessNote) + const ffmpegWorkerText = liveDetail.ffmpegPids?.length + ? liveDetail.ffmpegPids.join(', ') + : liveDetail.processStatus === 'online' + ? '未检测到' + : '—' + const logHeartbeatText = formatRuntimeSeconds(liveDetail.logAgeSeconds) + const preflightItems = useMemo(() => { + if (!isYoutube) return [] + const items: { label: string; ok: boolean; detail: string }[] = [] + if (multiChannelPro) { + items.push({ + label: '可启动线路', + ok: proReadyCount > 0, + detail: proVisibleLineCount ? `${proReadyCount}/${proVisibleLineCount} 条预检通过` : '还没有可用线路', + }) + items.push({ + label: '当前线路密钥', + ok: !!selectedProRow?.keyConfigured, + detail: selectedProRow?.keyConfigured ? `key …${selectedProRow.keyDisplay}` : '缺少 YouTube 串流密钥', + }) + items.push({ + label: '当前线路源站', + ok: (selectedProRow?.sourceCount ?? 0) > 0, + detail: selectedProRow ? `${selectedProRow.sourceCount} 条地址` : '请先选择一条线路', + }) + } else { + items.push({ + label: '串流密钥', + ok: singleKeyConfigured, + detail: singleKeyConfigured ? `key …${keySuffixForUi || '已配置'}` : '请填写 YouTube Studio 串流密钥', + }) + items.push({ + label: '源站地址', + ok: singleSourceCount > 0, + detail: singleSourceCount > 0 ? `${singleSourceCount} 条地址` : '请填写至少一个源站直播间地址', + }) + } + items.push({ + label: '编码能力', + ok: gpuCaps !== null, + detail: gpuStatusText, + }) + items.push({ + label: '运行态', + ok: liveDetail.isPushing === true || liveDetail.processStatus !== 'errored', + detail: liveBusinessLabel, + }) + return items + }, [ + gpuCaps, + gpuStatusText, + isYoutube, + keySuffixForUi, + liveBusinessLabel, + liveDetail.isPushing, + liveDetail.processStatus, + multiChannelPro, + proReadyCount, + proVisibleLineCount, + selectedProRow, + singleKeyConfigured, + singleSourceCount, + ]) + if (bootError) { return (
@@ -1678,23 +1920,137 @@ export default function App() { {!emptyHint && (
+ {isYoutube && ( +
+
+
+ YouTube 无人直播 +

+ {multiChannelPro ? '多频道 Pro 控制台' : '单频道直播控制台'} +

+

+ {multiChannelPro + ? '每个频道独立保存串流密钥、源站地址和 PM2 进程;启动前自动保存当前草稿并执行服务端预检。' + : '适合一个源站直播间推送到一个 YouTube 直播间;需要多路并发时打开右上角 Pro。'} +

+
+
+ + {livePreflightText} + + + + + {douyinHints[0] && ( + + )} +
+
+ +
+
+ 工作模式 + {multiChannelPro ? '多频道 Pro' : '单频道'} +
+
+ 运行状态 + + {multiChannelPro + ? `${proLiveCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 直播中` + : statusMeta.line} + +
+
+ 密钥 + + {multiChannelPro + ? `${proKeyCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 已配置` + : singleKeyConfigured ? '已配置' : '未配置'} + +
+
+ 源站 + {multiChannelPro ? `${proSourceCount} 条` : `${singleSourceCount} 条`} +
+
+ 编码 + {gpuStatusText} +
+ {multiChannelPro && selectedProRow && ( +
+ 当前频道 + {selectedProRow.channelName} +
+ )} +
+ +
+
+ 源站 + 直播间地址 +
+
+
+ 本机采集 + FFmpeg / GPU +
+
+
+ YouTube + RTMP 推流 +
+
+ +
+ {preflightItems.map((item) => ( +
+ +
+ {item.label} + {item.detail} +
+
+ ))} +
+
+ )} +

直播开关

{isYoutube && multiChannelPro && proLiveRows.length > 0 ? (
{proLiveRows.map((row) => { const ed = proChannelEditors[row.channelId] - const slotKey = committedChannelKeys[row.channelId]?.[0] ?? '' - const activeKey = (slotKey || '').trim() - const keySuffix = parseYoutubeStreamKeySuffix(`key = ${activeKey}`) const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') + const startDisabled = pmBusy || row.live || !row.ready const stopDisabled = pmBusy || !row.live const pm = proChannelPm2Process(row.channelId) const rowSelected = row.channelId === highlightedChannelId return (
{ const fromId = highlightedChannelIdRef.current @@ -1710,12 +2066,12 @@ export default function App() { } }} > - - {keySuffix || '—'} + + {row.keyDisplay}
{row.live ? : } - {row.live ? '直播中' : '未开播'} + {row.live ? '直播中' : row.ready ? '可启动' : '待配置'}
{href ? ( @@ -1734,19 +2090,23 @@ export default function App() { {room} · {row.channelName} )} + + {row.readyReason} + {row.sourceCount > 0 ? ` · ${row.sourceCount} 条源站` : ''} +
e.stopPropagation()}>
)} + {(isYoutube || isTiktok) && ( + <> +
+ 业务状态 + + {liveBusinessLabel} + +
+
+ FFmpeg workers + {ffmpegWorkerText} +
+
+ 日志心跳 + {logHeartbeatText} +
+ + )} {(isYoutube || isTiktok) && douyinPreviews.length > 0 && (
源站预览 diff --git a/electron/src/renderer/src/AppHostControls.tsx b/electron/src/renderer/src/AppHostControls.tsx index 303bb86..02d0c24 100644 --- a/electron/src/renderer/src/AppHostControls.tsx +++ b/electron/src/renderer/src/AppHostControls.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import UserAgreementDialog from './UserAgreementDialog' @@ -7,19 +7,68 @@ type LoginItem = { executableWillLaunchAtLogin?: boolean } +type AppInfo = { + name: string + version: string + locale: string + systemLocale: string + isPackaged: boolean + appPath: string + executablePath: string + platform: string + arch: string + chrome: string + electron: string + node: string +} + +type AppPaths = { + userData: string + logs: string + temp: string + backendRoot: string + backendConfig: string +} + +type AppMetric = { + type?: string + cpu?: { percentCPUUsage?: number } + memory?: { workingSetSize?: number; privateBytes?: number } +} + +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 AppHostControls() { const host = typeof window !== 'undefined' ? window.appHost : undefined const [login, setLogin] = useState(null) + const [info, setInfo] = useState(null) + const [paths, setPaths] = useState(null) + const [metrics, setMetrics] = useState([]) const [loading, setLoading] = useState(false) const [err, setErr] = useState(null) + const [notice, setNotice] = useState('') const [agreementOpen, setAgreementOpen] = useState(false) const load = useCallback(async () => { if (!host?.getLoginItemSettings) return setErr(null) try { - const li = await (host.getLoginItemSettings?.() ?? Promise.resolve(null)) + const [li, inf, pth, met] = await Promise.all([ + host.getLoginItemSettings?.() ?? Promise.resolve(null), + host.getInfo?.() ?? Promise.resolve(null), + host.getPaths?.() ?? Promise.resolve(null), + host.getMetrics?.() ?? Promise.resolve([]), + ]) setLogin((li as LoginItem) ?? null) + setInfo((inf as AppInfo) ?? null) + setPaths((pth as AppPaths) ?? null) + setMetrics(Array.isArray(met) ? (met as AppMetric[]) : []) } catch (e) { setErr(e instanceof Error ? e.message : String(e)) } @@ -31,6 +80,40 @@ export default function AppHostControls() { return () => window.clearInterval(id) }, [load]) + const openAt = + typeof login?.openAtLogin === 'boolean' + ? login.openAtLogin + : Boolean(login?.executableWillLaunchAtLogin) + + const processSummary = useMemo(() => { + const count = metrics.length + const cpu = metrics.reduce((sum, m) => sum + (m.cpu?.percentCPUUsage ?? 0), 0) + const mem = metrics.reduce((sum, m) => sum + (m.memory?.workingSetSize ?? 0), 0) + return { count, cpu, mem } + }, [metrics]) + + const runHost = useCallback( + async (fn: (() => Promise) | undefined, okMsg: string) => { + if (!fn) return + setLoading(true) + setErr(null) + try { + const r = await fn() + if (r && typeof r === 'object' && 'ok' in r && (r as { ok: boolean }).ok === false) { + setErr((r as { error?: string }).error ?? '操作失败') + } else { + setNotice(okMsg) + } + } catch (e) { + setErr(e instanceof Error ? e.message : String(e)) + } finally { + setLoading(false) + void load() + } + }, + [load], + ) + if (!host?.getPaths) { return ( <> @@ -48,23 +131,31 @@ export default function AppHostControls() { ) } - const openAt = - typeof login?.openAtLogin === 'boolean' - ? login.openAtLogin - : Boolean(login?.executableWillLaunchAtLogin) - return (
-

- 客户端与系统集成 -

+
+
+

+ 客户端与系统集成 +

+

+ Windows 懒人包本体控制:开机启动、运行路径、日志目录、应用重启与 Electron 进程状态。 +

+
+
+ {info?.isPackaged ? '发布包' : '开发模式'} + v{info?.version || '—'} +
+
+ {err && (

{err}

)} + {notice &&

{notice}

} -
+
开机启动
+

无人值守机器建议开启;如 Windows 阻止启动,可打开启动应用设置检查权限。

+ +
+ +
+
应用操作
+
+ + + +
+

+ Electron {info?.electron || '—'} · Chrome {info?.chrome || '—'} · Node {info?.node || '—'} ·{' '} + {info?.platform || '—'} {info?.arch || ''} +

+
+ +
+
Electron 进程
+
+ + {processSummary.count} + 进程 + + + {processSummary.cpu.toFixed(1)}% + CPU + + + {fmtBytes(processSummary.mem * 1024)} + 工作集 + +
+ +
+
关键目录
+
    + {[ + ['backendRoot', '程序根目录', paths?.backendRoot], + ['backendConfig', '直播配置', paths?.backendConfig], + ['logs', '客户端日志', paths?.logs], + ['userData', '用户数据', paths?.userData], + ['temp', '临时目录', paths?.temp], + ].map(([key, label, value]) => ( +
  • + {label} + {value || '—'} + +
  • + ))} +
+
+ diff --git a/electron/src/renderer/src/NetworkDashboard.tsx b/electron/src/renderer/src/NetworkDashboard.tsx index 79758c5..59211ef 100644 --- a/electron/src/renderer/src/NetworkDashboard.tsx +++ b/electron/src/renderer/src/NetworkDashboard.tsx @@ -68,6 +68,14 @@ function fmtMbps(v: number | null | undefined): string { return `${v.toFixed(2)} Mbps` } +function fmtBytes(n: number | null | 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` +} + function SiteRow({ title, site, @@ -187,6 +195,17 @@ export default function NetworkDashboard() { else void window.open(url, '_blank', 'noopener,noreferrer') }, []) + const openProxySettings = useCallback(() => { + void window.appHost?.openSystemSettings?.('proxy') + }, []) + + const routesPill = + view.routes.different == null + ? ['net-routes__pill--unk', '分流未知'] + : view.routes.different + ? ['net-routes__pill--yes', '境内外出口已区分'] + : ['net-routes__pill--no', '境内外出口可能未区分'] + return (
@@ -194,8 +213,14 @@ export default function NetworkDashboard() {

网络仪表盘

+

+ 重点检查 YouTube/Google 出口与源站出口。无人直播卡顿时,先看 Google 是否可达、上行是否稳定、境内外是否分流。 +

+ @@ -227,6 +252,49 @@ export default function NetworkDashboard() { />
+ +
+ 线路判断 + {routesPill[1]} +

{loading ? '检测中…' : view.routes.note || '暂无线路说明'}

+
+ +
+

测速采样

+
+ {[ + ['海外', view.speed.overseas], + ['国内', view.speed.domestic], + ].map(([title, speed]) => { + const s = speed as SpeedBlock + return ( +
+
{String(title)}
+
+ 下载 + {loading ? '—' : fmtMbps(s.download_mbps)} +
+
+ 上传 + {loading ? '—' : fmtMbps(s.upload_mbps)} +
+
+ 采样 + {loading ? '—' : fmtBytes(s.download_bytes_sampled)} +
+ {!loading && (s.download_error || s.upload_error) && ( +

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

+ )} +
+ ) + })} +
+

+ 上行测速依赖目标站点是否接受 POST,失败不等于无法推流;YouTube 直播更看重持续上行和代理/TUN 规则。 +

+
) } diff --git a/electron/src/renderer/src/RemoteBinding.tsx b/electron/src/renderer/src/RemoteBinding.tsx index 9b449a0..9afa33c 100644 --- a/electron/src/renderer/src/RemoteBinding.tsx +++ b/electron/src/renderer/src/RemoteBinding.tsx @@ -30,6 +30,7 @@ export default function RemoteBinding() { const [selectedIp, setSelectedIp] = useState('') const [payloadJson, setPayloadJson] = useState('') const [qrDataUrl, setQrDataUrl] = useState('') + const [payloadVersion, setPayloadVersion] = useState(0) const load = useCallback(async () => { if (!rb?.getStatus) { @@ -126,7 +127,7 @@ export default function RemoteBinding() { return () => { cancelled = true } - }, [rb, selectedIp, status?.port]) + }, [rb, selectedIp, status?.port, status?.hasToken, payloadVersion]) const onGenerateToken = async () => { if (!rb?.generateToken) return @@ -140,11 +141,22 @@ export default function RemoteBinding() { } await initApiFromMain() await load() + setPayloadVersion((v) => v + 1) } finally { setBusy(false) } } + const onCopyPayload = async () => { + if (!payloadJson) return + try { + await navigator.clipboard.writeText(payloadJson) + setMsg('绑定 JSON 已复制') + } catch { + setMsg('复制失败,请手动选择下方 JSON') + } + } + if (!rb?.getStatus) { return (
@@ -206,6 +218,9 @@ export default function RemoteBinding() {
             {payloadJson}
           
+

内容与二维码一致(v1 JSON,含 baseUrl / port / token)。

)} diff --git a/electron/src/renderer/src/SystemMetrics.tsx b/electron/src/renderer/src/SystemMetrics.tsx index 57d6391..da0312d 100644 --- a/electron/src/renderer/src/SystemMetrics.tsx +++ b/electron/src/renderer/src/SystemMetrics.tsx @@ -17,6 +17,7 @@ type Metrics = { }> network?: { up_mbps?: number | null; down_mbps?: number | null } ffmpeg_processes?: Array<{ pid?: number; name?: string; rss?: number }> + boot_time_unix?: number | null error?: string } @@ -56,22 +57,50 @@ export default function SystemMetrics() { const m = data?.memory const net = data?.network + const uptimeLabel = + data?.boot_time_unix != null + ? (() => { + const sec = Math.max(0, Math.floor(Date.now() / 1000 - data.boot_time_unix)) + const d = Math.floor(sec / 86400) + const h = Math.floor((sec % 86400) / 3600) + const min = Math.floor((sec % 3600) / 60) + return d > 0 ? `${d} 天 ${h} 小时` : `${h} 小时 ${min} 分` + })() + : '—' + const warnings = [ + data?.cpu_percent != null && data.cpu_percent >= 85 ? 'CPU 持续过高会导致推流掉帧' : '', + m?.percent != null && m.percent >= 85 ? '内存压力偏高,建议关闭无关程序' : '', + data?.disks?.some((d) => (d.percent ?? 0) >= 90) ? '至少一个磁盘空间紧张,录制缓存可能失败' : '', + data?.ffmpeg_processes?.length ? '' : '当前未检测到 FFmpeg 进程;直播中若仍为空,说明未真正推流', + ].filter(Boolean) return ( <>
-

- 本机负载与网速 -

-

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

+
+
+

+ 本机负载与网速 +

+

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

+
+ 已运行 {uptimeLabel} +
{err && (

{err}

)} + {warnings.length > 0 && ( +
+ {warnings.map((w) => ( + {w} + ))} +
+ )}
CPU
diff --git a/electron/src/renderer/src/WorkspaceDashboard.tsx b/electron/src/renderer/src/WorkspaceDashboard.tsx index 88cc772..71ac055 100644 --- a/electron/src/renderer/src/WorkspaceDashboard.tsx +++ b/electron/src/renderer/src/WorkspaceDashboard.tsx @@ -210,6 +210,70 @@ export default function WorkspaceDashboard() {

)} +
+
+
YouTube 频道配置
+
    +
  • + 频道数 + {overview?.youtubeChannelCount ?? 0} +
  • +
  • + 配对策略 + {overview?.relayPairingPolicy || '一源站对应一 YouTube key'} +
  • +
+ {overview?.youtubeChannels?.length ? ( +
+ {overview.youtubeChannels.slice(0, 6).map((c) => ( +

+ {c.name} · key {c.keyPreview || '未配置'} · {c.douyinPreview || '未配置源站'} +

+ ))} +
+ ) : ( +

暂无频道配置

+ )} +
+
+
配置体检
+ {overview?.relayValidationErrors?.length ? ( +
+ {overview.relayValidationErrors.slice(0, 5).map((x) => ( +

{x}

+ ))} +
+ ) : ( +

未发现跨频道 key / 源站地址冲突

+ )} + {overview?.relayNotes?.length ? ( +
+ {overview.relayNotes.slice(0, 4).map((x) => ( +

{x}

+ ))} +
+ ) : null} +
+
+
微信机器人
+
    +
  • + 配置 + {overview?.wechat?.configured ? '已配置' : '未配置'} +
  • +
  • + 状态 + {overview?.wechat?.online ? '在线' : '离线'} +
  • +
  • + 账号 + {overview?.wechat?.nickName || overview?.wechat?.wcId || '—'} +
  • +
+ {overview?.wechat?.proxyError ?

{overview.wechat.proxyError}

: null} +
+
+ {proMode && (

Pro 转播实况

diff --git a/electron/src/renderer/src/api.ts b/electron/src/renderer/src/api.ts index 1855158..70ee0c1 100644 --- a/electron/src/renderer/src/api.ts +++ b/electron/src/renderer/src/api.ts @@ -1,11 +1,11 @@ -const base = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8001' +let apiBase = import.meta.env.VITE_API_BASE || 'http://127.0.0.1:8001' /** 由 Electron 主进程从 config/web2_api_token.txt 注入;无则走 VITE / localStorage */ let bearerFromMain: string | null = null export function apiUrl(path: string): string { const p = path.startsWith('/') ? path : `/${path}` - return `${base.replace(/\/$/, '')}${p}` + return `${apiBase.replace(/\/$/, '')}${p}` } function getBearerToken(): string | null { @@ -22,7 +22,14 @@ function getBearerToken(): string | null { export async function initApiFromMain(): Promise { bearerFromMain = null try { - const w = (window as Window & { web2Remote?: { getApiToken?: () => Promise } }).web2Remote + const w = (window as Window & { + web2Remote?: { + getApiBase?: () => Promise + getApiToken?: () => Promise + } + }).web2Remote + const b = await w?.getApiBase?.() + if (b && /^https?:\/\//i.test(String(b).trim())) apiBase = String(b).trim() const t = await w?.getApiToken?.() if (t && String(t).trim()) bearerFromMain = String(t).trim() } catch { diff --git a/electron/src/renderer/src/vite-env.d.ts b/electron/src/renderer/src/vite-env.d.ts index 29c5352..d3711ac 100644 --- a/electron/src/renderer/src/vite-env.d.ts +++ b/electron/src/renderer/src/vite-env.d.ts @@ -15,6 +15,7 @@ interface Window { openExternal?: (url: string) => Promise } web2Remote?: { + getApiBase?: () => Promise getApiToken?: () => Promise } remoteBinding?: { @@ -63,6 +64,20 @@ interface Window { } appHost?: { onNavigate?: (cb: (nav: string) => void) => () => void + getInfo?: () => Promise<{ + name: string + version: string + locale: string + systemLocale: string + isPackaged: boolean + appPath: string + executablePath: string + platform: string + arch: string + chrome: string + electron: string + node: string + }> getLoginItemSettings?: () => Promise> setLoginItemOpenAtLogin?: (v: boolean) => Promise<{ ok: true } | { ok: false; error: string }> relaunch?: () => Promise @@ -76,7 +91,9 @@ interface Window { backendRoot: string backendConfig: string }> + openPath?: (key: string) => Promise<{ ok: true } | { ok: false; error: string }> addRecentConfig?: () => Promise openLogsFolder?: () => Promise + openSystemSettings?: (page: string) => Promise<{ ok: true } | { ok: false; error: string }> } } diff --git a/electron/src/types/sql-js.d.ts b/electron/src/types/sql-js.d.ts new file mode 100644 index 0000000..15cf2eb --- /dev/null +++ b/electron/src/types/sql-js.d.ts @@ -0,0 +1,26 @@ +declare module 'sql.js' { + export type SqlValue = string | number | null | Uint8Array + + export type QueryExecResult = { + columns: string[] + values: SqlValue[][] + } + + export class Database { + constructor(data?: BufferSource | number[]) + run(sql: string, params?: SqlValue[]): void + exec(sql: string): QueryExecResult[] + export(): Uint8Array + close(): void + } + + export type SqlJsStatic = { + Database: typeof Database + } + + export type SqlJsConfig = { + locateFile?: (file: string) => string + } + + export default function initSqlJs(config?: SqlJsConfig): Promise +} diff --git a/electron/tsconfig.json b/electron/tsconfig.json index ccea286..4ef9955 100644 --- a/electron/tsconfig.json +++ b/electron/tsconfig.json @@ -8,10 +8,16 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + "paths": { + "@shared/*": ["src/shared/*"], + "@renderer/*": ["src/renderer/src/*"] + } }, "include": [ "electron.vite.config.ts", + "src/**/*.d.ts", "src/shared/**/*.ts", "src/main/**/*.ts", "src/preload/**/*.ts", diff --git a/web2.py b/web2.py index 016793b..70361bf 100644 --- a/web2.py +++ b/web2.py @@ -4,9 +4,11 @@ from pathlib import Path import asyncio import html import os +import time from contextlib import asynccontextmanager from typing import Optional +import psutil from fastapi.middleware.cors import CORSMiddleware from web2_supervisor import ( @@ -32,6 +34,7 @@ from web2_relay_pro import ( add_relay_channel, build_relay_live_status, ensure_default_relay_config, + extract_url_lines_from_ini, first_youtube_pm2_name, get_channel_by_id, get_channel_detail, @@ -45,7 +48,7 @@ from web2_relay_pro import ( update_channel_keys_list, write_url_config_for_channel, ) -from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini +from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini, validate_youtube_keys from web2_host_caps import gpu_status_payload, machine_summary_payload from web2_douyin_preview import fetch_douyin_anchor_name from web2_pocketbase import REQUIRE_PB, pb_auth_refresh_and_validate @@ -428,6 +431,85 @@ async def read_log_path(path: str, lines: int) -> str: return f"读取日志失败 ({path}): {str(e)}\n" +def _log_age_seconds(path: str | None) -> float | None: + if not path: + return None + try: + p = Path(path) + if not p.exists(): + return None + return max(0.0, time.time() - p.stat().st_mtime) + except OSError: + return None + + +def _ffmpeg_pids_for_process(info: dict, process_name: str) -> list[int]: + """优先按内置 supervisor 的父子进程关系识别;兼容 PM2 时回退命令行关键字匹配。""" + pids: list[int] = [] + seen: set[int] = set() + pid = info.get("pid") + if isinstance(pid, int) and pid > 0: + try: + parent = psutil.Process(pid) + for child in parent.children(recursive=True): + try: + name = (child.name() or "").lower() + if "ffmpeg" in name and child.pid not in seen: + pids.append(child.pid) + seen.add(child.pid) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + pass + if pids: + return pids + + needle = (process_name or "").strip().lower() + if not needle: + return pids + for proc in psutil.process_iter(["pid", "name", "cmdline"]): + try: + name = (proc.info.get("name") or "").lower() + if "ffmpeg" not in name: + continue + flat = " ".join(str(x) for x in (proc.info.get("cmdline") or [])).lower() + if needle in flat and int(proc.info["pid"]) not in seen: + pids.append(int(proc.info["pid"])) + seen.add(int(proc.info["pid"])) + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, ValueError): + continue + return pids + + +def _business_status_from_runtime( + *, + process_status: str, + recent_log: str, + recent_error: str, + ffmpeg_pids: list[int], + log_age_seconds: float | None, +) -> tuple[str, str]: + log = (recent_log or "").lower() + err = (recent_error or "").lower() + if process_status in {"invalid", "not_found", "stopped"}: + return "stopped", "进程未运行" + if process_status not in {"online", "running"}: + return "unknown", "等待进程状态稳定" + if "至少需要一个有效" in recent_log or "串流密钥" in recent_log or "youtube key missing" in log: + return "misconfigured", "缺少或无法使用 YouTube 串流密钥" + if any(x in err for x in ("traceback", "error", "exception", "failed", "errno")): + return "error", "近期错误日志中出现异常" + if ffmpeg_pids: + return "streaming", f"检测到 {len(ffmpeg_pids)} 个 FFmpeg 推流进程" + if "[relay_meta]" in log or "正在直播中" in recent_log: + return "source_live", "源站已开播,但暂未检测到 FFmpeg worker" + if log_age_seconds is not None and log_age_seconds > 180: + return "stale", f"日志 {int(log_age_seconds)} 秒未更新" + if "等待直播" in recent_log or "检测直播间中" in recent_log: + return "waiting_source", "正在等待源站开播" + return "monitoring", "进程在线,正在监测源站" + + # ---------------- 配置路由(youtube.ini,仅 youtube) ---------------- @app.get("/get_config") async def get_config(process: str = Query(..., description="进程名")): @@ -495,6 +577,9 @@ async def youtube_ini_model_post(request: Request, process: str = Query(..., des if not isinstance(keys, list): keys = [""] keys = [str(x) for x in keys] + ok_keys, key_msg = validate_youtube_keys(keys) + if not ok_keys: + return JSONResponse({"error": key_msg}, status_code=400) try: active_index = int(data.get("activeIndex", 0)) except Exception: @@ -585,6 +670,36 @@ async def _stop_legacy_youtube_if_online() -> None: await asyncio.sleep(0.5) +async def _legacy_youtube_conflict_response(process: str, sc: Optional[str]) -> JSONResponse | None: + legacy_pm2 = first_youtube_pm2_name(_script_entries_payload()) + if sc != "youtube2.py" or process != legacy_pm2: + return None + online_relays = await _relay_youtube_online_pm2_names() + if not online_relays: + return None + return JSONResponse( + { + "output": ( + "当前有多频道 Pro 推流进程正在运行,不能同时启动/重启单频道 youtube2;" + f"请先停止:{', '.join(online_relays)}" + ) + }, + status_code=400, + ) + + +def _relay_channel_ready_message(channel_id: str) -> str | None: + d = get_channel_detail(CONFIG_DIR, channel_id) + if not d: + return "频道不存在" + if not str(d.get("youtubeKey") or "").strip(): + return "请先配置该频道的 YouTube 串流密钥" + body = read_url_config_for_channel(CONFIG_DIR, channel_id) + if not extract_url_lines_from_ini(body): + return "请先为该频道填写至少一个源站直播间地址" + return None + + # ---------------- PM2 控制路由 ---------------- @app.get("/start") async def start(request: Request, process: str = Query(..., description="进程名")): @@ -597,20 +712,13 @@ async def start(request: Request, process: str = Query(..., description="进程 return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) cid = relay_channel_id_from_pm2_name(process) sc = script_for_pm2(process) - legacy_pm2 = first_youtube_pm2_name(_script_entries_payload()) - if not cid and sc == "youtube2.py" and process == legacy_pm2: - online_relays = await _relay_youtube_online_pm2_names() - if online_relays: - return JSONResponse( - { - "output": ( - "当前有多路转播进程在运行,与单进程 youtube2 互斥(单进程会读全局 URL_config.ini)。" - f"请先停止:{', '.join(online_relays)}" - ) - }, - status_code=400, - ) + conflict = await _legacy_youtube_conflict_response(process, sc) + if conflict is not None: + return conflict if cid: + ready_msg = _relay_channel_ready_message(cid) + if ready_msg: + return JSONResponse({"output": ready_msg}, status_code=400) ok, msg = materialize_relay_channel_youtube_ini(CONFIG_DIR, cid) if not ok: return JSONResponse({"output": msg}, status_code=400) @@ -654,15 +762,27 @@ async def restart(request: Request, process: str = Query(..., description="进 if not process_valid(process): return JSONResponse({"output": f"无效进程名: {process}"}, status_code=400) cid = relay_channel_id_from_pm2_name(process) - if cid: - ok, msg = materialize_relay_channel_youtube_ini(CONFIG_DIR, cid) - if not ok: - return JSONResponse({"output": msg}, status_code=400) - await _stop_legacy_youtube_if_online() - output = await control_restart(process) + sc = script_for_pm2(process) + conflict = await _legacy_youtube_conflict_response(process, sc) + if conflict is not None: + return conflict + + stop_output = await control_stop(process) + await asyncio.sleep(1.0) if not process.startswith("web"): await force_kill_ffmpeg_cross_platform(process) - return JSONResponse({"output": output}) + await asyncio.sleep(0.35) + + if cid: + ready_msg = _relay_channel_ready_message(cid) + if ready_msg: + return JSONResponse({"output": f"{stop_output}\n{ready_msg}"}, status_code=400) + ok, msg = materialize_relay_channel_youtube_ini(CONFIG_DIR, cid) + if not ok: + return JSONResponse({"output": f"{stop_output}\n{msg}"}, status_code=400) + await _stop_legacy_youtube_if_online() + start_output = await control_start(process) + return JSONResponse({"output": f"{stop_output}\n{start_output}"}) @app.get("/status") @@ -685,11 +805,29 @@ async def status(process: str = Query(..., description="进程名")): recent_log = "进程未运行或未由本机管理器启动。\n" recent_error = "" + ffmpeg_pids = _ffmpeg_pids_for_process(info, process) + out_age = _log_age_seconds(info.get("out_path")) + err_age = _log_age_seconds(info.get("err_path")) + ages = [x for x in (out_age, err_age) if x is not None] + log_age_seconds = min(ages) if ages else None + business_status, business_note = _business_status_from_runtime( + process_status=str(info["process_status"]), + recent_log=recent_log, + recent_error=recent_error, + ffmpeg_pids=ffmpeg_pids, + log_age_seconds=log_age_seconds, + ) + return JSONResponse({ "raw_status": full_status, "process_status": info["process_status"], "recent_log": recent_log, "recent_error": recent_error, + "business_status": business_status, + "business_note": business_note, + "ffmpeg_pids": ffmpeg_pids, + "log_age_seconds": log_age_seconds, + "is_pushing": business_status == "streaming", }) @@ -819,4 +957,4 @@ async def process_monitor(): # ---------------- 首页 ---------------- @app.get("/") async def index(): - return FileResponse(BASE_DIR / "index.html") \ No newline at end of file + return FileResponse(BASE_DIR / "index.html") diff --git a/web2_relay_pro.py b/web2_relay_pro.py index c10f3da..fb1f900 100644 --- a/web2_relay_pro.py +++ b/web2_relay_pro.py @@ -10,6 +10,8 @@ import time from pathlib import Path from typing import Any, Callable, Awaitable +from web2_youtube_ini import parse_youtube_ini, serialize_youtube_ini + # 与 web2_client_overview 一致的去重键 def _norm_douyin_url(url: str) -> str: u = (url or "").strip() @@ -380,8 +382,23 @@ def write_url_config_for_channel(config_dir: Path, channel_id: str, content: str def write_youtube_ini_single_key(config_dir: Path, key: str) -> None: """写入单路 youtube.ini([youtube] key=)。""" - lines = ["[youtube]", f"key = {key.strip()}", ""] - (config_dir / "youtube.ini").write_text("\n".join(lines), encoding="utf-8") + config_dir = Path(config_dir) + ini = config_dir / "youtube.ini" + options: dict[str, str] = {} + header = "" + if ini.exists(): + try: + parsed = parse_youtube_ini(ini.read_text(encoding="utf-8-sig")) + raw_options = parsed.get("options") + if isinstance(raw_options, dict): + options = {str(k): str(v) for k, v in raw_options.items()} + raw_header = parsed.get("header") + if isinstance(raw_header, str): + header = raw_header + except Exception: + options = {} + header = "" + ini.write_text(serialize_youtube_ini([key.strip()], 0, options, header), encoding="utf-8") def sync_channel_to_legacy_files(config_dir: Path, channel_id: str) -> tuple[bool, str]: diff --git a/web2_supervisor.py b/web2_supervisor.py index c1c105c..fa1a63a 100644 --- a/web2_supervisor.py +++ b/web2_supervisor.py @@ -231,12 +231,14 @@ class BuiltinSupervisor: if not self._pid_alive(pid): return { "process_status": "stopped" if entry else "not_found", + "pid": pid if entry else None, "out_path": out_path, "err_path": err_path, } return { "process_status": "online", + "pid": pid, "out_path": out_path, "err_path": err_path, } diff --git a/web2_youtube_ini.py b/web2_youtube_ini.py index 2a189b8..9ef1d72 100644 --- a/web2_youtube_ini.py +++ b/web2_youtube_ini.py @@ -7,6 +7,31 @@ import re from typing import Any +def norm_stream_key(key: str) -> str: + k = (key or "").strip() + if not k: + return "" + if k.lower().startswith(("rtmp://", "rtmps://")): + parts = k.rstrip("/").split("/") + k = parts[-1] if parts else k + return k.strip().lower() + + +def validate_youtube_keys(keys: list[str]) -> tuple[bool, str]: + cleaned = [str(x).strip() for x in keys if str(x).strip()] + if not cleaned: + return False, "至少需要一个有效的 YouTube 串流密钥" + seen: set[str] = set() + for key in cleaned: + nk = norm_stream_key(key) + if not nk: + continue + if nk in seen: + return False, "同一配置内不能重复使用相同的 YouTube 串流密钥" + seen.add(nk) + return True, "" + + def parse_youtube_ini(content: str) -> dict[str, Any]: if not (content or "").strip(): return {