diff --git a/electron/src/renderer/src/App.css b/electron/src/renderer/src/App.css index d59358f..46e935f 100644 --- a/electron/src/renderer/src/App.css +++ b/electron/src/renderer/src/App.css @@ -1860,6 +1860,38 @@ body.ready { color: #5bd38a; } +.pro-live-switch-tag.is-running { + color: #c4b5fd; +} + +.pro-live-switch-tag.is-running { + color: #c4b5fd; +} + +.pro-live-switch-emoji { + font-size: 12px; + line-height: 1; +} + +.pro-live-switch-title { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.pro-live-switch-title strong { + font-size: 13px; + font-weight: 650; + color: var(--text); +} + +.pro-live-switch-subname { + font-size: 11px; + color: var(--text-muted); +} + .pro-live-switch-mid { min-width: 0; } diff --git a/electron/src/renderer/src/App.tsx b/electron/src/renderer/src/App.tsx index 8111e2e..3905d55 100644 --- a/electron/src/renderer/src/App.tsx +++ b/electron/src/renderer/src/App.tsx @@ -21,18 +21,19 @@ import { import CameraLivePage from './CameraLivePage' import PullStreamPage from './PullStreamPage' import SidebarBrand from './components/SidebarBrand' -import QualityGuardBanner from './components/QualityGuardBanner' import RelayLogPanel from './components/RelayLogPanel' import RelayValidationBanner from './components/RelayValidationBanner' import ConfigBackupsInline from './components/ConfigBackupsInline' -import LivePipeline from './components/LivePipeline' +import LiveProLaneRow from './components/live/LiveProLaneRow' +import LiveProcessLiveLogCard from './components/live/LiveProcessLiveLogCard' +import LiveStreamActionBtn from './components/live/LiveStreamActionBtn' +import LiveYoutubeOverview from './components/live/LiveYoutubeOverview' +import { makeProcBusy } from './lib/procBusy' import { NavIcon } from './components/icons/NavIcons' -import LivePulse from './components/ui/LivePulse' import { getPbAuthGateEnabled, PB_AUTH_GATE_EVENT } from './pbAuthGate' import { pbAuthRefresh, type PbUserRecord } from './pbAuthClient' import NetworkDashboard from './NetworkDashboard' import SystemMetrics from './SystemMetrics' -import LiveYoutubeMonitor from './components/LiveYoutubeMonitor' import { logLiveEvent, logUserAction } from './workspace' import { STORAGE_MULTI_CHANNEL_PRO, @@ -45,16 +46,22 @@ import { parseYoutubeStreamKeySuffix, } from './youtubeConfigUtils' import { isObsScript, isTiktokScript, isYoutubeScript, pm2NameFromScript } from './scriptUtils' -import { classifyRowActivity, relayLiveStatusPollMs, statusPollMs } from './adaptivePoll' +import { classifyRowActivity, classifyProcessMonitorActivity, processMonitorPollMs, relayLiveStatusPollMs, statusPollMs } from './adaptivePoll' import { useAdaptiveInterval, usePageVisible } from './hooks/useAdaptiveInterval' import { businessStatusLabel, formatDurationCn, - formatFfmpegSpeed, - formatRuntimeSeconds, + liveHeaderStatus, statusPresentation, type StatusVariant, } from './liveStatusLabels' +import { + isLivePushingRow, + liveProcRowByPm2, + pm2StatusOnline, + proLaneStatusLabel, + type LiveProcRow, +} from './lib/liveProcessRows' import type { GpuCaps, LiveDetailState, ControlStatusPayload } from './types/live' import { liveDetailFromStatus } from './types/live' import { @@ -95,37 +102,6 @@ function douyinRoomAndHref(douyinUrl: string | undefined, urlIni: string): { roo type NavId = AppNavId -function IconForbidden({ className }: { className?: string }) { - return ( - - - - ) -} - -function IconLiveBroadcast({ className }: { className?: string }) { - return ( - - - - ) -} - -function IconLiveIdle({ className }: { className?: string }) { - return ( - - - - ) -} - const NAV_ITEMS: { id: NavId; label: string; hint: string }[] = [ { id: 'record', label: '直播', hint: '抖音转播推流与直播控制' }, { id: 'camera', label: '摄像头', hint: '本机采集直推 YouTube' }, @@ -171,7 +147,8 @@ export default function App() { const [controlNotice, setControlNotice] = useState('') const [liveTick, setLiveTick] = useState(0) - const [loadingAction, setLoadingAction] = useState(null) + const [procBusy, setProcBusy] = useState(null) + const [statusPollPaused, setStatusPollPaused] = useState(false) const [saveYoutubeLoading, setSaveYoutubeLoading] = useState(false) const [saveUrlLoading, setSaveUrlLoading] = useState(false) @@ -221,6 +198,7 @@ export default function App() { const [proRelayStatus, setProRelayStatus] = useState(null) const [relayValidationErrors, setRelayValidationErrors] = useState([]) const [relayValidationNotes, setRelayValidationNotes] = useState([]) + const [liveProcessRows, setLiveProcessRows] = useState([]) useEffect(() => { document.documentElement.setAttribute('data-theme', theme) @@ -323,10 +301,22 @@ export default function App() { [liveDetail], ) const statusPollInterval = useMemo( - () => statusPollMs({ activity: statusActivity, pageVisible }), - [statusActivity, pageVisible], + () => statusPollMs({ activity: statusActivity, pageVisible, paused: statusPollPaused }), + [statusActivity, pageVisible, statusPollPaused], ) - const proPushingCount = (proRelayStatus?.channelStatuses ?? []).filter((s) => s.live).length + const proPushingCount = useMemo( + () => liveProcessRows.filter((r) => isLivePushingRow(r)).length, + [liveProcessRows], + ) + const processMonitorActivity = useMemo( + () => classifyProcessMonitorActivity(liveProcessRows), + [liveProcessRows], + ) + const processMonitorPollInterval = useMemo( + () => processMonitorPollMs({ activity: processMonitorActivity, pageVisible }), + [processMonitorActivity, pageVisible], + ) + const liveProcByPm2 = useMemo(() => liveProcRowByPm2(liveProcessRows), [liveProcessRows]) const relayPollInterval = useMemo( () => relayLiveStatusPollMs({ pushingLanes: proPushingCount, pageVisible }), [proPushingCount, pageVisible], @@ -754,6 +744,18 @@ export default function App() { }) }, [pbAuthGateEnabled]) + const refreshLiveProcessMonitor = useCallback(async () => { + if (!isYoutube) return + try { + const data = await apiFetchJson<{ entries?: LiveProcRow[] }>( + apiUrl('/process_monitor?family=youtube&light=1'), + ) + setLiveProcessRows(data.entries ?? []) + } catch { + /* ignore */ + } + }, [isYoutube]) + const runPm2Action = useCallback( async (action: string, processOverride?: string) => { const proc = processOverride ?? currentProcess @@ -763,11 +765,15 @@ export default function App() { const allowed = await ensureCanControlLive() if (!allowed) return } - setLoadingAction(action) + setProcBusy(makeProcBusy(action as 'start' | 'stop' | 'restart', proc)) } try { + const statusQs = + action === 'status' + ? `?process=${encodeURIComponent(proc)}&light=1` + : `?process=${encodeURIComponent(proc)}` const data = await apiFetchJson( - `${apiUrl(`/${action}`)}?process=${encodeURIComponent(proc)}`, + `${apiUrl(`/${action}`)}${statusQs}`, ) if (action === 'status') { @@ -797,9 +803,10 @@ export default function App() { lastLiveStatusRef.current = { process: proc, status: ps } logLiveEvent({ eventType: 'process_status', process: proc, status: ps }) } - const { line, variant } = statusPresentation(ps) + const detail = liveDetailFromStatus(data, ps) + setLiveDetail(detail) + const { line, variant } = liveHeaderStatus(detail, ps) setStatusMeta({ line, variant }) - setLiveDetail(liveDetailFromStatus(data, ps)) } else { const e = getEntry(proc) const who = e ? e.label : '当前任务' @@ -829,16 +836,19 @@ export default function App() { }) } setControlNotice(`已对「${who}」执行:${actionLabel}\n${out}`) - setTimeout(() => void runPm2Action('status', processOverride), 1500) + setTimeout(() => { + void runPm2Action('status', processOverride) + void refreshLiveProcessMonitor() + }, 1500) } } catch (err) { setControlNotice(`操作失败:${err instanceof Error ? err.message : String(err)}`) setStatusMeta({ line: '状态未知', variant: 'unknown' }) } finally { - if (!isStatus) setLoadingAction(null) + if (!isStatus) setProcBusy(null) } }, - [currentProcess, getEntry, isYoutube, ytIniModel, urlText, multiChannelPro, ensureCanControlLive], + [currentProcess, getEntry, isYoutube, ytIniModel, urlText, multiChannelPro, ensureCanControlLive, refreshLiveProcessMonitor], ) const pollCurrentStatus = useCallback(() => { @@ -881,6 +891,16 @@ export default function App() { !!(isYoutube && multiChannelPro && nav === 'record'), ) + useEffect(() => { + void refreshLiveProcessMonitor() + }, [refreshLiveProcessMonitor]) + + useAdaptiveInterval( + () => void refreshLiveProcessMonitor(), + processMonitorPollInterval, + !!(isYoutube && nav === 'record'), + ) + useEffect(() => { document.body.classList.add('ready') }, []) @@ -1112,7 +1132,7 @@ export default function App() { const allowed = await ensureCanControlLive() if (!allowed) return const pm = proChannelPm2Process(channelId) - setLoadingAction(action) + setProcBusy(makeProcBusy(action, pm)) try { const edBeforeUrlSave = proChannelEditors[channelId] let urlOverride: string | undefined @@ -1159,14 +1179,17 @@ export default function App() { }) } setControlNotice(`已对「${who}」执行:${actionLabel}\n${out}`) - setTimeout(() => void runPm2Action('status', pm), 1500) + setTimeout(() => { + void runPm2Action('status', pm) + void refreshLiveProcessMonitor() + }, 1500) } catch (err) { setControlNotice(`操作失败:${err instanceof Error ? err.message : String(err)}`) } finally { - setLoadingAction(null) + setProcBusy(null) } }, - [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelBundle, ensureCanControlLive], + [getEntry, runPm2Action, proChannelEditors, loadProUrlTextFor, saveProChannelBundle, ensureCanControlLive, refreshLiveProcessMonitor], ) const runProChannelStart = useCallback( @@ -1366,27 +1389,32 @@ export default function App() { }, [keySuffixForUi]) const relayPs = liveDetail.processStatus - const isRelayProcessLive = relayPs === 'online' - const pmBusy = loadingAction !== null - const startLiveDisabled = pmBusy || isRelayProcessLive || !backendReady - const stopLiveDisabled = pmBusy || !isRelayProcessLive || !backendReady + const isRelayProcessLive = pm2StatusOnline(relayPs) + const isPushingNow = isLivePushingRow({ + process_status: liveDetail.processStatus, + business_status: liveDetail.businessStatus, + is_pushing: liveDetail.isPushing, + }) + const pmBusy = procBusy !== null const hasRelayValidationBlock = relayValidationErrors.length > 0 + const notifyLive = useCallback((message: string) => setControlNotice(message), []) const proLiveRows = useMemo(() => { - const liveMap = new Map( - (proRelayStatus?.channelStatuses ?? []).map((s) => [s.channelId, s.live]), - ) const rows: { channelId: string channelName: string douyinUrl: string - live: boolean + online: boolean + pushing: boolean + statusLabel: string keyConfigured: boolean keyText: string keyDisplay: string sourceCount: number ready: boolean readyReason: string + urlText: string + processRow?: LiveProcRow }[] = [] for (const c of proChannelsList) { const saved = committedChannelKeys[c.id] @@ -1397,30 +1425,43 @@ export default function App() { : ed?.urlText || c.douyinUrl || '' const sourceCount = countConfigUrlLines(sourceText) const keyConfigured = !!keyText - const live = liveMap.get(c.id) ?? false + const pm = proChannelPm2Process(c.id) + const processRow = liveProcByPm2.get(pm) + const online = pm2StatusOnline(processRow?.process_status) + const pushing = isLivePushingRow(processRow) + const ready = keyConfigured && sourceCount > 0 const isCurrentEditor = c.id === highlightedChannelId const serverConfigured = c.configured ?? (keyConfigured || sourceCount > 0) - if (!serverConfigured && !live && !isCurrentEditor) continue + if (!serverConfigured && !online && !isCurrentEditor) continue const readyReason = !keyConfigured ? '缺少 YouTube 串流密钥' : sourceCount <= 0 ? '缺少源站直播地址' : '预检通过' + const statusLabel = processRow + ? proLaneStatusLabel(processRow) + : ready + ? '可启动' + : '待配置' rows.push({ channelId: c.id, channelName: c.name, douyinUrl: c.douyinUrl ?? '', - live, + online, + pushing, + statusLabel, keyConfigured, keyText, keyDisplay: parseYoutubeStreamKeySuffix(`key = ${keyText}`) || c.keyPreview || '未配置', sourceCount, - ready: keyConfigured && sourceCount > 0, + ready, readyReason, + urlText: sourceText, + processRow, }) } return rows - }, [proChannelsList, proRelayStatus, committedChannelKeys, proChannelEditors, highlightedChannelId]) + }, [proChannelsList, committedChannelKeys, proChannelEditors, highlightedChannelId, liveProcByPm2]) const youtubeRelayWatchItems = useMemo(() => { if (!isYoutube) return [] @@ -1451,9 +1492,9 @@ export default function App() { const singleSourceCount = useMemo(() => countConfigUrlLines(urlText), [urlText]) const singleKeyConfigured = !!youtubeIniActiveKey(ytIniModel) - const proConfiguredLineCount = proLiveRows.filter((r) => r.keyConfigured || r.sourceCount > 0 || r.live).length + const proConfiguredLineCount = proLiveRows.filter((r) => r.keyConfigured || r.sourceCount > 0 || r.online).length const proVisibleLineCount = proLiveRows.length - const proLiveCount = proLiveRows.filter((r) => r.live).length + const proLiveCount = proLiveRows.filter((r) => r.pushing).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) @@ -1478,12 +1519,6 @@ export default function App() { ? '已检测 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 }[] = [] @@ -1779,149 +1814,61 @@ 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} -
- )} -
- - {!backendReady ? ( -

- 本地控制服务连接中…直播开关暂不可用,连接成功后自动恢复。 -

- ) : null} - - - - setControlNotice(text)} - /> - - - -
- {preflightItems.map((item) => ( -
- -
- {item.label} - {item.detail} -
-
- ))} -
-
+ )} -
+

直播开关

{isYoutube && multiChannelPro && proLiveRows.length > 0 ? (
{proLiveRows.map((row) => { const ed = proChannelEditors[row.channelId] const { room, href } = douyinRoomAndHref(row.douyinUrl, ed?.urlText ?? '') - const startDisabled = pmBusy || row.live || !row.ready || !backendReady || hasRelayValidationBlock - const stopDisabled = pmBusy || !row.live const pm = proChannelPm2Process(row.channelId) - const rowSelected = row.channelId === highlightedChannelId return ( -
{ + row={row} + pm={pm} + selected={row.channelId === highlightedChannelId} + procBusy={procBusy} + backendReady={backendReady} + hasRelayValidationBlock={hasRelayValidationBlock} + room={room} + href={href} + onOpenExternal={openExternalUrl} + onNotify={notifyLive} + onStart={() => void runProChannelStart(row.channelId)} + onStop={() => void runPm2Action('stop', pm)} + onRestart={() => void runProChannelRestart(row.channelId)} + onSelect={() => { const fromId = highlightedChannelIdRef.current const toId = row.channelId highlightedChannelIdRef.current = toId @@ -1934,84 +1881,7 @@ export default function App() { }) } }} - > - - {row.keyDisplay} - -
- {row.live ? : } - {row.live ? '直播中' : row.ready ? '可启动' : '待配置'} -
-
- {href ? ( - - ) : ( - - {room} · {row.channelName} - - )} - - {row.readyReason} - {row.sourceCount > 0 ? ` · ${row.sourceCount} 条源站` : ''} - -
-
e.stopPropagation()}> - - - -
-
+ /> ) })}
@@ -2023,39 +1893,78 @@ export default function App() {

) : (
- {( - [ - ['start', '开始直播', '开始直播当前任务', startLiveDisabled], - ['stop', '停止直播', '停止当前直播', stopLiveDisabled], - ['restart', '重新开始', '先停再开', pmBusy], - ['status', '刷新状态', '马上看最新状态', false], - ] as const - ).map(([action, label, title, disabled]) => ( - - ))} + void runPm2Action('start')} + /> + void runPm2Action('stop')} + /> + void runPm2Action('restart')} + /> +
)}
+ {entries.length > 0 && currentProcess ? ( + setStatusPollPaused((p) => !p)} + onManualRefresh={() => pollCurrentStatus()} + douyinPreviews={douyinPreviews} + douyinHints={douyinHints} + controlNotice={controlNotice} + preferredTitle={ + multiChannelPro && selectedProRow ? `${selectedProRow.channelName}` : undefined + } + onOpenExternal={openExternalUrl} + /> + ) : null} + {isYoutube && multiChannelPro && ( <> @@ -2528,125 +2437,6 @@ export default function App() {
)} -
-
-

当前直播日志

- 自适应刷新 -
-
-
- 直播状态 - {statusMeta.line} -
-
- 本次已直播 - - {liveDurationLabel} - -
- {isYoutube && ( -
- YouTube key 尾码(参考) - {keySuffixForUi || '—'} -
- )} - {(isYoutube || isTiktok) && ( - <> -
- 业务状态 - - {liveBusinessLabel} - -
-
- FFmpeg workers - {ffmpegWorkerText} -
- {(liveDetail.ffmpegSpeed != null || liveDetail.ffmpegFrame != null) && ( -
- FFmpeg 进度 - - {liveDetail.ffmpegFrame != null ? `frame ${liveDetail.ffmpegFrame}` : '—'} - {' · '} - speed {formatFfmpegSpeed(liveDetail.ffmpegSpeed)} - {liveDetail.ffmpegSpeedLow ? '(偏低)' : ''} - {liveDetail.ffmpegProgressStalled ? '(停滞)' : ''} - -
- )} -
- 日志心跳 - {logHeartbeatText} -
- - )} - {(isYoutube || isTiktok) && douyinPreviews.length > 0 && ( -
- 源站预览 - - {douyinPreviews.map((p, i) => ( -
- {p.url || '—'} - {' => '} - {p.ok && (p.anchor_name || '').trim() - ? `主播: ${p.anchor_name}` - : (p.error || '未解析到昵称')} -
- ))} -
-
- )} - {(isYoutube || isTiktok) && ( -
- 源站房间线索 - - {douyinHints.length ? ( - douyinHints.map((id, i) => ( - - {i > 0 ? '、' : ''} - { - e.preventDefault() - openExternalUrl(`https://live.douyin.com/${id}`) - }} - > - {id} - - - )) - ) : ( - '(地址列表中未解析到源站链接)' - )} - -
- )} - {liveDetail.processStatus !== 'not_found' && ( - <> -
-
进程输出摘要
-
-                        {liveDetail.recentLog?.trim() || '(暂无)'}
-                      
-
-
-
异常信息
-
-                        {liveDetail.recentError?.trim() || '(暂无)'}
-                      
-
- - )} - {controlNotice ? ( -
-
操作反馈
-
{controlNotice}
-
- ) : null} -
-
- {isYoutube && }
)} diff --git a/electron/src/renderer/src/adaptivePoll.ts b/electron/src/renderer/src/adaptivePoll.ts index 49fa361..d335006 100644 --- a/electron/src/renderer/src/adaptivePoll.ts +++ b/electron/src/renderer/src/adaptivePoll.ts @@ -60,6 +60,21 @@ export function statusPollMs(options: { } } +export function processMonitorPollMs(options: { + activity: MonitorActivity + pageVisible: boolean +}): number { + if (!options.pageVisible) return HIDDEN_TAB_POLL_MS + switch (options.activity) { + case 'hot': + return 800 + case 'warm': + return 2500 + default: + return 5000 + } +} + export function relayLiveStatusPollMs(options: { pushingLanes: number pageVisible: boolean @@ -72,3 +87,13 @@ export function qualityGuardPollMs(options: { pushingLanes: number; pageVisible: if (!options.pageVisible) return HIDDEN_TAB_POLL_MS return options.pushingLanes > 0 ? 6000 : 15000 } + +export function formatPollBadge(ms: number): string { + if (ms <= 0) return '已暂停' + if (ms >= 30_000) return '约 30 秒' + if (ms >= 15_000) return '约 15 秒' + if (ms >= 5000) return '约 5 秒' + if (ms >= 2000) return '约 2 秒' + if (ms >= 800) return '约 1 秒' + return `${ms}ms` +} diff --git a/electron/src/renderer/src/components/live/LivePipelineStrip.tsx b/electron/src/renderer/src/components/live/LivePipelineStrip.tsx new file mode 100644 index 0000000..a5b03b7 --- /dev/null +++ b/electron/src/renderer/src/components/live/LivePipelineStrip.tsx @@ -0,0 +1,85 @@ +import type { AppNavId } from '@shared/featureFlags' + +export type LivePipelineNavTarget = Extract + +export type LivePipelineStep = { + label: string + sub?: string + tone: 'proxy' | 'encode' | 'studio' | 'system' + target?: LivePipelineNavTarget + openUrl?: string + active?: boolean +} + +type Props = { + title?: string + subtitle?: string + steps: LivePipelineStep[] + onNavigate?: (target: LivePipelineNavTarget) => void + onOpenUrl?: (url: string) => void +} + +export default function LivePipelineStrip({ + title = 'YouTube 转播链路', + subtitle = '与「网络」页相同风格:代理 → 编码推流 → 工作室侧操作。', + steps, + onNavigate, + onOpenUrl, +}: Props) { + return ( +
+
+
+

{title}

+

{subtitle}

+
+
+
+ {steps.map((step, i) => { + const clickable = !!(step.openUrl || (step.target && onNavigate)) + const body = ( + <> + {step.label} + {step.sub ? {step.sub} : null} + + ) + return ( +
+ {clickable ? ( + + ) : ( +
+ {body} +
+ )} + {i < steps.length - 1 ? ( + + › + + ) : null} +
+ ) + })} +
+

点击高亮块可跳转到对应功能页

+
+ ) +} diff --git a/electron/src/renderer/src/components/live/LiveProLaneRow.tsx b/electron/src/renderer/src/components/live/LiveProLaneRow.tsx new file mode 100644 index 0000000..36faa2e --- /dev/null +++ b/electron/src/renderer/src/components/live/LiveProLaneRow.tsx @@ -0,0 +1,155 @@ +import LiveStreamActionBtn from './LiveStreamActionBtn' +import { + formatFfmpegStatsBrief, + laneStatusEmoji, + proLanePrimaryTitle, + type LiveProcRow, +} from '../../lib/liveProcessRows' + +type ProRow = { + channelId: string + channelName: string + douyinUrl: string + online: boolean + pushing: boolean + statusLabel: string + keyConfigured: boolean + keyText: string + keyDisplay: string + sourceCount: number + ready: boolean + readyReason: string + urlText: string + processRow?: LiveProcRow +} + +type Props = { + row: ProRow + pm: string + selected: boolean + procBusy: string | null + backendReady: boolean + hasRelayValidationBlock: boolean + onSelect: () => void + onOpenExternal: (url: string) => void + onNotify: (msg: string) => void + onStart: () => void + onStop: () => void + onRestart: () => void + room: string + href: string +} + +export default function LiveProLaneRow({ + row, + pm, + selected, + procBusy, + backendReady, + hasRelayValidationBlock, + onSelect, + onOpenExternal, + onNotify, + onStart, + onStop, + onRestart, + room, + href, +}: Props) { + const laneTitle = proLanePrimaryTitle(row.channelName, row.processRow, row.urlText) + const statsText = formatFfmpegStatsBrief(row.processRow) + const sourceUrl = row.processRow?.source_url || href + + return ( +
+ + {row.keyDisplay} + +
+ + {laneStatusEmoji(row.processRow)} + + {row.statusLabel} +
+
+
+ {laneTitle} + {laneTitle !== row.channelName ? ( + {row.channelName} + ) : null} +
+ {sourceUrl ? ( + + ) : ( + + {room} · {row.channelName} + + )} + + {row.readyReason} + {row.sourceCount > 0 ? ` · ${row.sourceCount} 条源站` : ''} + {statsText ? ` · ${statsText}` : ''} + +
+
e.stopPropagation()}> + + + +
+
+ ) +} diff --git a/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx b/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx new file mode 100644 index 0000000..2d436bf --- /dev/null +++ b/electron/src/renderer/src/components/live/LiveProcessLiveLogCard.tsx @@ -0,0 +1,250 @@ +import { useEffect, useRef } from 'react' +import { formatPollBadge } from '../../adaptivePoll' +import { businessStatusLabel, formatFfmpegSpeed } from '../../liveStatusLabels' +import type { LiveDetailState } from '../../types/live' +import { IconRefresh } from '../network/NetworkIcons' + +type DouyinPreview = { url?: string; ok?: boolean; anchor_name?: string; error?: string } + +type Props = { + currentProc: string + statusLine: string + liveDurationLabel: string + liveDetail: LiveDetailState + keySuffix?: string + showYoutubeKey?: boolean + showRelayMetrics?: boolean + pollIntervalMs: number + pollPaused: boolean + onTogglePollPause: () => void + onManualRefresh: () => void + douyinPreviews?: DouyinPreview[] + douyinHints?: string[] + controlNotice?: string + preferredTitle?: string + onOpenExternal?: (url: string) => void +} + +function businessBadgeClass(raw?: string): string { + const s = (raw || '').trim().toLowerCase() + if (s === 'streaming' || s === 'relay_slow') return 'live-log-metric__pill--ok' + if ( + s === 'waiting_source' || + s === 'monitoring' || + s === 'source_live' || + s === 'relay_starting' + ) { + return 'live-log-metric__pill--warn' + } + if ( + s === 'misconfigured' || + s === 'youtube_key_rejected' || + s === 'error' || + s === 'stale' || + s === 'source_stalled' + ) { + return 'live-log-metric__pill--bad' + } + return 'live-log-metric__pill--idle' +} + +function processBadgeClass(raw?: string): string { + const s = (raw || '').trim().toLowerCase() + if (s === 'online' || s === 'running') return 'live-log-metric__pill--ok' + if (s === 'stopped' || s === 'stopping') return 'live-log-metric__pill--idle' + if (s === 'not_found' || s === 'errored' || s === 'error') return 'live-log-metric__pill--bad' + return 'live-log-metric__pill--idle' +} + +export default function LiveProcessLiveLogCard({ + currentProc, + statusLine, + liveDurationLabel, + liveDetail, + keySuffix = '', + showYoutubeKey = false, + showRelayMetrics = false, + pollIntervalMs, + pollPaused, + onTogglePollPause, + onManualRefresh, + douyinPreviews = [], + douyinHints = [], + controlNotice = '', + preferredTitle, + onOpenExternal, +}: Props) { + const outRef = useRef(null) + const errRef = useRef(null) + const pushingNow = + liveDetail.isPushing || + ['streaming', 'relay_slow'].includes((liveDetail.businessStatus || '').trim().toLowerCase()) + const youtubeKeyRejected = + (liveDetail.businessStatus || '').trim().toLowerCase() === 'youtube_key_rejected' + const businessLabel = businessStatusLabel(liveDetail.businessStatus, liveDetail.businessNote) + const ffmpegWorkerText = liveDetail.ffmpegPids?.length + ? liveDetail.ffmpegPids.join(', ') + : liveDetail.processStatus === 'online' + ? '转推活跃(PID 未采集)' + : '无' + const pollBadge = formatPollBadge(pollPaused ? 0 : pollIntervalMs) + + useEffect(() => { + if (pollPaused) return + const el = outRef.current + if (el) el.scrollTop = el.scrollHeight + }, [liveDetail.recentLog, pollPaused]) + + useEffect(() => { + if (pollPaused) return + const el = errRef.current + if (el) el.scrollTop = el.scrollHeight + }, [liveDetail.recentError, pollPaused]) + + return ( +
+
+
+

当前直播日志

+ {pushingNow ? ( + + + 推流中 + + ) : null} + {currentProc || '—'} + {pollBadge} +
+
+ + +
+
+ + {youtubeKeyRejected ? ( +
+ ⚠️ + YouTube 拒绝了串流密钥,请到 Studio 核对密钥是否属于当前直播间。 +
+ ) : null} + +
+
+ 进程 + + {statusLine} + +
+
+ 业务 + + {businessLabel} + +
+
+ 本次时长 + {liveDurationLabel} +
+ {showRelayMetrics ? ( +
+ FFmpeg + + {liveDetail.ffmpegFrame != null ? `f${liveDetail.ffmpegFrame}` : '—'} + {' · '} + {formatFfmpegSpeed(liveDetail.ffmpegSpeed)} + {liveDetail.ffmpegSpeedLow ? ' 偏低' : ''} + {liveDetail.ffmpegProgressStalled ? ' 停滞' : ''} + +
+ ) : null} +
+ + {preferredTitle ? ( +

+ 📺 {preferredTitle} +

+ ) : null} + + {showYoutubeKey ? ( +

+ YouTube key 尾码 {keySuffix || '—'} + {liveDetail.logAgeSeconds != null ? ` · 日志心跳 ${Math.round(liveDetail.logAgeSeconds)}s` : ''} +

+ ) : null} + + {(showRelayMetrics && douyinPreviews.length > 0) || douyinHints.length > 0 ? ( +
+ {douyinPreviews.length > 0 ? ( +
+ 源站预览 + {douyinPreviews.map((p, i) => ( +
+ {p.url || '—'} + {' => '} + {p.ok && (p.anchor_name || '').trim() + ? `主播: ${p.anchor_name}` + : p.error || '未解析到昵称'} +
+ ))} +
+ ) : null} + {douyinHints.length > 0 ? ( +
+ 房间线索 + + {douyinHints.map((id, i) => ( + + {i > 0 ? '、' : ''} + + + ))} + +
+ ) : null} +
+ ) : null} + + {liveDetail.processStatus !== 'not_found' ? ( +
+
+
stdout
+
+              {liveDetail.recentLog?.trim() || '(暂无)'}
+            
+
+
+
stderr
+
+              {liveDetail.recentError?.trim() || '(暂无)'}
+            
+
+
+ ) : null} + + {controlNotice ? ( +
+
操作反馈
+
{controlNotice}
+
+ ) : null} + +

+ FFmpeg workers {ffmpegWorkerText} +

+
+ ) +} diff --git a/electron/src/renderer/src/components/live/LiveStreamActionBtn.tsx b/electron/src/renderer/src/components/live/LiveStreamActionBtn.tsx new file mode 100644 index 0000000..4aee85f --- /dev/null +++ b/electron/src/renderer/src/components/live/LiveStreamActionBtn.tsx @@ -0,0 +1,88 @@ +import { parseProcBusy } from '../../lib/procBusy' + +type ActionKind = 'start' | 'stop' | 'restart' + +type Props = { + kind: ActionKind + busyKind?: ActionKind + targetPm2: string + procBusy: string | null + online: boolean + pushing: boolean + label: string + className?: string + compact?: boolean + baseLock?: boolean + onRun: () => void + onNotify?: (message: string) => void +} + +export default function LiveStreamActionBtn({ + kind, + busyKind, + targetPm2, + procBusy, + online, + pushing, + label, + className = '', + compact = false, + baseLock = false, + onRun, + onNotify, +}: Props) { + const pb = parseProcBusy(procBusy) + const forThisProc = pb && pb.pm2 === targetPm2 + const actionForBusy = busyKind ?? kind + const loading = !!(forThisProc && pb?.action === actionForBusy) + const siblingBusy = !!(forThisProc && pb?.action !== actionForBusy) + const blockStart = kind === 'start' && pushing + const blockStop = kind === 'stop' && !online + const blockRestart = kind === 'restart' && !online + const blocked = blockStart || blockStop || blockRestart + const hardLock = baseLock || loading || siblingBusy + + const notify = (msg: string) => onNotify?.(msg) + + const onClick = () => { + if (loading) return + if (siblingBusy) { + notify('请等待当前操作完成') + return + } + if (blockStart) { + notify('已在推流中,无需重复开始') + return + } + if (blockStop) { + notify('当前未在直播,无法停止') + return + } + if (blockRestart) { + notify('进程未运行时请使用「开始」') + return + } + if (baseLock) return + onRun() + } + + const sizeCls = compact ? 'live-action-btn--compact' : '' + const kindCls = `live-action-btn--${kind}` + const dimmed = (blocked && !loading) || siblingBusy + + return ( + + ) +} diff --git a/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx b/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx new file mode 100644 index 0000000..755c7dd --- /dev/null +++ b/electron/src/renderer/src/components/live/LiveYoutubeOverview.tsx @@ -0,0 +1,247 @@ +import type { AppNavId } from '@shared/featureFlags' +import LivePipeline from '../LivePipeline' +import LiveYoutubeMonitor from '../LiveYoutubeMonitor' +import QualityGuardBanner from '../QualityGuardBanner' +import LivePulse from '../ui/LivePulse' +import LivePipelineStrip from './LivePipelineStrip' +import type { LiveDetailState } from '../../types/live' + +type ProRow = { + channelId: string + channelName: string + keyConfigured: boolean +} + +type PreflightItem = { label: string; ok: boolean; detail: string } + +type RelayWatchItem = { relayId: string; label: string; input: string } + +type Props = { + nav: AppNavId + multiChannelPro: boolean + liveReady: boolean + livePreflightText: string + statusLine: string + proLiveCount: number + proConfiguredLineCount: number + proVisibleLineCount: number + proKeyCount: number + proSourceCount: number + singleKeyConfigured: boolean + singleSourceCount: number + selectedProRow?: ProRow + gpuStatusText: string + backendReady: boolean + liveDetail: LiveDetailState + isRelayProcessLive: boolean + monitorProcess: string + youtubeRelayWatchItems: RelayWatchItem[] + highlightedChannelId: string + preflightItems: PreflightItem[] + douyinHint?: string + onNavigate: (nav: AppNavId) => void + onOpenYoutubeStudio: () => void + onOpenExternal: (url: string) => void + onControlNotice: (text: string) => void +} + +export default function LiveYoutubeOverview({ + nav, + multiChannelPro, + liveReady, + livePreflightText, + statusLine, + proLiveCount, + proConfiguredLineCount, + proVisibleLineCount, + proKeyCount, + proSourceCount, + singleKeyConfigured, + singleSourceCount, + selectedProRow, + gpuStatusText, + backendReady, + liveDetail, + isRelayProcessLive, + monitorProcess, + youtubeRelayWatchItems, + highlightedChannelId, + preflightItems, + douyinHint, + onNavigate, + onOpenYoutubeStudio, + onOpenExternal, + onControlNotice, +}: Props) { + const encodeSub = liveDetail.isPushing || isRelayProcessLive ? '推流中' : '待命' + const studioSub = multiChannelPro && selectedProRow ? selectedProRow.channelName : 'Studio' + + return ( + <> + + +
+
+
+ 🎬 YouTube 无人直播 +

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

+

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

+
+
+ + + {livePreflightText} + + + + + {douyinHint ? ( + + ) : null} +
+
+ +
+
+ + ⚙️ + + 工作模式 + {multiChannelPro ? '多频道 Pro' : '单频道'} +
+
+ + 📡 + + 运行状态 + + {multiChannelPro + ? `${proLiveCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 路推流中` + : statusLine} + +
+
+ + 🔑 + + 密钥 + + {multiChannelPro + ? `${proKeyCount}/${proConfiguredLineCount || proVisibleLineCount || 0} 已配置` + : singleKeyConfigured + ? '已配置' + : '未配置'} + +
+
+ + 🔗 + + 源站 + {multiChannelPro ? `${proSourceCount} 条` : `${singleSourceCount} 条`} +
+
+ + 🖥️ + + 编码 + {gpuStatusText} +
+ {multiChannelPro && selectedProRow ? ( +
+ + 📺 + + 当前频道 + {selectedProRow.channelName} +
+ ) : null} +
+ + {!backendReady ? ( +

+ 本地控制服务连接中…直播开关暂不可用,连接成功后自动恢复。 +

+ ) : null} + + + + + + + +
+ {preflightItems.map((item) => ( +
+ +
+ {item.label} + {item.detail} +
+
+ ))} +
+
+ + ) +} diff --git a/electron/src/renderer/src/live-polish.css b/electron/src/renderer/src/live-polish.css new file mode 100644 index 0000000..e9523b3 --- /dev/null +++ b/electron/src/renderer/src/live-polish.css @@ -0,0 +1,410 @@ +/* 直播页组件(对齐 sh2 LiveStreamActionBtn / LiveProcessLiveLogCard) */ + +.live-action-btn { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 2.75rem; + padding: 10px 16px; + border-radius: var(--radius-md); + border: 1px solid var(--border-strong); + background: var(--bg-elevated); + color: var(--text); + font-size: 14px; + font-weight: 650; + cursor: pointer; + transition: + opacity 0.2s ease, + transform 0.2s var(--ease), + box-shadow 0.2s var(--ease); +} + +.live-action-btn--compact { + min-height: 2rem; + min-width: 72px; + padding: 8px 14px; + font-size: 13px; +} + +.live-action-btn--start { + border-color: rgba(91, 211, 138, 0.45); + color: #5bd38a; +} + +.live-action-btn--stop { + border-color: rgba(255, 120, 120, 0.35); + color: #ff8a8a; +} + +.live-action-btn--restart { + border-color: rgba(124, 156, 255, 0.35); + color: var(--accent-2); +} + +.live-action-btn:hover:not(.is-locked):not(:disabled) { + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.live-action-btn.is-locked, +.live-action-btn:disabled { + cursor: not-allowed; +} + +.live-action-btn.is-locked:not(.is-loading) { + opacity: 0.45; +} + +.live-action-btn.is-sibling-busy { + opacity: 0.35; +} + +.live-action-btn.is-dimmed:not(.is-loading) { + opacity: 0.45; + cursor: not-allowed; +} + +.live-action-btn.is-loading { + pointer-events: none; +} + +.live-action-btn__spinner { + width: 14px; + height: 14px; + border: 2px solid currentColor; + border-right-color: transparent; + border-radius: 50%; + animation: ui-startup-orbit 0.75s linear infinite; +} + +.live-log-card { + animation: ui-fade-slide-up 0.42s var(--ease) both; +} + +.live-log-card__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + margin-bottom: 12px; +} + +.live-log-card__title-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + min-width: 0; +} + +.live-log-card__live-pill { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 700; + color: var(--success); + background: var(--success-soft); + border: 1px solid rgba(61, 214, 140, 0.28); +} + +.live-log-card__live-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--success); + animation: live-pulse-dot 1.4s ease-in-out infinite; +} + +.live-log-card__proc { + font-size: 11px; + font-family: var(--font-mono); + color: var(--text-muted); +} + +.live-log-card__poll { + font-size: 11px; + color: var(--text-muted); + padding: 2px 8px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.04); + border: 1px solid var(--border-subtle); +} + +.live-log-card__poll.is-paused { + color: var(--warn); + border-color: rgba(246, 173, 85, 0.28); + background: var(--warn-soft); +} + +.live-log-card__tools { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.live-log-card__alert { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 0 0 12px; + padding: 10px 12px; + border-radius: 10px; + font-size: 12px; + line-height: 1.45; + color: #fecaca; + background: rgba(245, 101, 101, 0.12); + border: 1px solid rgba(245, 101, 101, 0.28); +} + +.live-log-metrics { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 8px; + margin-bottom: 12px; +} + +.live-log-metric { + padding: 8px 10px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border-subtle); +} + +.live-log-metric__k { + display: block; + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-muted); +} + +.live-log-metric__v { + font-size: 12px; + font-weight: 650; + color: var(--text); +} + +.live-log-metric__v--mono { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; +} + +.live-log-metric__pill { + display: inline-block; + padding: 3px 8px; + border-radius: 999px; + font-size: 11px; + font-weight: 650; + border: 1px solid transparent; +} + +.live-log-metric__pill--ok { + color: var(--success); + background: var(--success-soft); + border-color: rgba(61, 214, 140, 0.25); +} + +.live-log-metric__pill--warn { + color: var(--warn); + background: var(--warn-soft); + border-color: rgba(246, 173, 85, 0.25); +} + +.live-log-metric__pill--bad { + color: #fecaca; + background: rgba(245, 101, 101, 0.12); + border-color: rgba(245, 101, 101, 0.28); +} + +.live-log-metric__pill--idle { + color: var(--text-muted); + background: rgba(255, 255, 255, 0.04); + border-color: var(--border-subtle); +} + +.live-log-card__lane, +.live-log-card__meta { + margin: 0 0 10px; + font-size: 12px; + color: var(--text-muted); +} + +.live-log-card__meta code { + font-family: var(--font-mono); + color: var(--accent-2); +} + +.live-log-card__source { + display: grid; + gap: 8px; + margin-bottom: 12px; + padding: 10px 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--border-subtle); +} + +.live-log-card__source-k { + display: block; + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.live-log-card__panes { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.live-log-card__pane-k { + margin-bottom: 4px; + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--text-muted); +} + +.live-log-card__pane .live-log-detail__pre { + max-height: 220px; +} + +.live-log-card__workers { + margin: 10px 0 0; + font-size: 11px; + color: var(--text-muted); +} + +@media (max-width: 720px) { + .live-log-card__panes { + grid-template-columns: 1fr; + } +} + +/* ── LivePipelineStrip(对齐 sh2 跨页链路条) ── */ +.net-block--live-strip { + margin-bottom: 0; +} + +.live-pipeline-strip__row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 10px 6px; + margin-top: 18px; +} + +.live-pipeline-strip__segment { + display: flex; + align-items: center; + gap: 6px; +} + +.live-pipeline-strip__chev { + color: var(--text-muted); + font-size: 18px; + line-height: 1; + opacity: 0.55; + user-select: none; +} + +.live-pipeline-strip__step { + display: flex; + min-height: 4rem; + min-width: 7.5rem; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 10px 14px; + border-radius: var(--radius-lg); + border: 1px solid rgba(255, 255, 255, 0.08); + background: var(--bg-elevated); + text-align: center; + transition: + transform 0.2s var(--ease), + box-shadow 0.2s var(--ease), + border-color 0.2s var(--ease); +} + +button.live-pipeline-strip__step { + cursor: pointer; + color: inherit; + font: inherit; +} + +button.live-pipeline-strip__step:hover { + transform: translateY(-1px); + border-color: rgba(167, 139, 250, 0.45); + box-shadow: 0 8px 24px rgba(88, 28, 135, 0.18); +} + +.live-pipeline-strip__step--proxy { + background: linear-gradient(145deg, rgba(139, 92, 246, 0.22), rgba(217, 70, 239, 0.12)); +} + +.live-pipeline-strip__step--encode { + background: linear-gradient(145deg, rgba(34, 211, 238, 0.18), rgba(59, 130, 246, 0.14)); +} + +.live-pipeline-strip__step--studio { + background: linear-gradient(145deg, rgba(52, 211, 153, 0.18), rgba(20, 184, 166, 0.12)); +} + +.live-pipeline-strip__step--system { + background: linear-gradient(145deg, rgba(251, 191, 36, 0.14), rgba(245, 158, 11, 0.1)); +} + +.live-pipeline-strip__step.is-active { + border-color: rgba(167, 139, 250, 0.55); + box-shadow: 0 0 0 1px rgba(167, 139, 250, 0.25); +} + +.live-pipeline-strip__label { + font-size: 12px; + font-weight: 700; + color: var(--text); +} + +.live-pipeline-strip__sub { + font-size: 10px; + color: var(--text-muted); + line-height: 1.3; +} + +.live-pipeline-strip__hint { + margin: 14px 0 0; + text-align: center; + font-size: 10px; + color: var(--text-muted); + opacity: 0.75; +} + +@media (max-width: 640px) { + .live-pipeline-strip__chev { + display: none; + } + + .live-pipeline-strip__row { + flex-direction: column; + align-items: stretch; + } + + .live-pipeline-strip__step { + width: 100%; + } +} diff --git a/electron/src/renderer/src/liveStatusLabels.ts b/electron/src/renderer/src/liveStatusLabels.ts index 5119725..de88ce0 100644 --- a/electron/src/renderer/src/liveStatusLabels.ts +++ b/electron/src/renderer/src/liveStatusLabels.ts @@ -8,9 +8,16 @@ export type StatusVariant = | 'unknown' | 'empty' -/** PM2 进程状态 → 用户可读文案 */ +type StatusRowLike = { + process_status?: string + business_status?: string + business_note?: string + is_pushing?: boolean +} + +/** PM2 进程状态 → 用户可读文案(进程在线 ≠ 正在推流,推流态见 liveHeaderStatusLine) */ export function statusPresentation(processStatus: string): { line: string; variant: StatusVariant } { - if (processStatus === 'online') return { line: '正在直播', variant: 'online' } + if (processStatus === 'online') return { line: '运行中', variant: 'online' } if (processStatus === 'stopped') return { line: '已停止', variant: 'stopped' } if (processStatus === 'errored') return { line: '出现异常', variant: 'error' } if (processStatus === 'launching' || processStatus === 'starting') @@ -38,6 +45,33 @@ const BUSINESS_LABELS: Record = { inactive: '未运行', } +/** 顶栏状态:优先业务态(推流中/等待上游),否则进程态 */ +export function liveHeaderStatus( + row: StatusRowLike, + processStatus: string, +): { line: string; variant: StatusVariant } { + const business = (row.business_status || '').trim().toLowerCase() + const pushing = + row.is_pushing === true || business === 'streaming' || business === 'relay_starting' + if (pushing || (business && business !== 'inactive' && business !== 'stopped')) { + const line = businessStatusLabel(row.business_status, row.business_note) + if (line !== '暂无业务态') { + const variant: StatusVariant = + business === 'streaming' || pushing + ? 'online' + : business === 'error' || + business === 'youtube_key_rejected' || + business === 'misconfigured' + ? 'error' + : business === 'relay_slow' || business === 'waiting_source' || business === 'relay_starting' + ? 'warn' + : 'online' + return { line, variant } + } + } + return statusPresentation(processStatus) +} + export function businessStatusLabel(raw?: string, note?: string): string { const s = (raw || '').trim().toLowerCase() if (s && BUSINESS_LABELS[s]) return BUSINESS_LABELS[s] diff --git a/electron/src/renderer/src/main.tsx b/electron/src/renderer/src/main.tsx index fd17b7c..8785f89 100644 --- a/electron/src/renderer/src/main.tsx +++ b/electron/src/renderer/src/main.tsx @@ -6,6 +6,7 @@ import './App.css' import './ui-polish.css' import './network-polish.css' import './system-polish.css' +import './live-polish.css' const root = document.getElementById('root') if (root) { diff --git a/web2.py b/web2.py index 88850e2..a3673d0 100644 --- a/web2.py +++ b/web2.py @@ -29,7 +29,8 @@ from web2_youtube_oauth import ( oauth_status as youtube_oauth_status_snapshot, ) from web2_client_overview import build_client_overview -from web2_process_runtime import summarize_process_activity +from web2_process_runtime import process_family, summarize_process_activity +from web2_douyin_source_display import extract_douyin_source_meta from web2_config_governance import ( list_config_backups, restore_config_backup, @@ -67,6 +68,7 @@ from web2_relay_pro import ( watch_url_for_relay_process, write_url_config_for_channel, ) +from web2_youtube_preflight import youtube_start_preflight_message 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_camera import camera_config_ready, list_capture_devices, load_camera_config, save_camera_config @@ -555,7 +557,12 @@ def _ffmpeg_pids_for_process(info: dict, process_name: str) -> list[int]: 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: + env_name = "" + try: + env_name = str(proc.environ().get("LIVE_PM2_PROCESS_NAME") or "").strip().lower() + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + env_name = "" + if env_name == needle or (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): @@ -649,26 +656,48 @@ async def _youtube_guard_rows(targets: list[str]) -> tuple[list[dict], dict[str, return rows, config_by_process -async def _status_payload_for_process(process: str) -> dict: +async def _status_payload_for_process(process: str, *, light: bool = False) -> dict: info = await control_get_process_info(process) - recent_log = await read_log_path(info["out_path"], MAX_LOG_LINES) - recent_error = await read_log_path(info["err_path"], MAX_LOG_LINES) - if info["process_status"] == "not_found": + ps = str(info.get("process_status") or "") + if ps == "not_found": 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 + ffmpeg_pids: list[int] = [] + log_age_seconds = None + elif ps in {"stopped", "stopping"}: + recent_log = "" + recent_error = "" + ffmpeg_pids = [] + log_age_seconds = None + else: + online = ps in {"online", "running", "launching"} + read_logs = online or not light + if light and process_family(process) == "youtube": + log_lines = 80 + err_lines = 24 + else: + log_lines = MAX_LOG_LINES + err_lines = MAX_LOG_LINES + if read_logs: + recent_log = await read_log_path(info["out_path"], log_lines) + recent_error = await read_log_path(info["err_path"], err_lines) + else: + recent_log = "" + recent_error = "" + ffmpeg_pids = _ffmpeg_pids_for_process(info, process) if online else [] + 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 activity = summarize_process_activity( process, - str(info["process_status"]), + ps, recent_log, recent_error, log_age_seconds=log_age_seconds, ffmpeg_pids=ffmpeg_pids, ) + source_meta = _youtube_source_meta(process, recent_log) return { "process": process, "process_status": info["process_status"], @@ -685,9 +714,17 @@ async def _status_payload_for_process(process: str) -> dict: "ffmpeg_speed": activity.get("ffmpeg_speed"), "ffmpeg_progress_stalled": activity.get("ffmpeg_progress_stalled"), "ffmpeg_speed_low": activity.get("ffmpeg_speed_low"), + **source_meta, } +def _youtube_source_meta(process: str, recent_log: str = "") -> dict: + sc = script_for_pm2(process) + if not sc or not is_youtube_script(sc): + return {} + return extract_douyin_source_meta(_url_lines_for_process(process), recent_log) + + # ---------------- 配置路由(youtube.ini,仅 youtube) ---------------- @app.get("/get_config") async def get_config(process: str = Query(..., description="进程名")): @@ -984,6 +1021,10 @@ async def start(request: Request, process: str = Query(..., description="进程 cam_msg = _stream_worker_ready_message(process) if cam_msg: return JSONResponse({"output": cam_msg}, status_code=400) + if is_youtube_script(sc): + preflight_msg = await youtube_start_preflight_message() + if preflight_msg: + return JSONResponse({"output": preflight_msg}, status_code=400) output = await control_start(process) return JSONResponse({"output": output}) @@ -1045,12 +1086,20 @@ async def restart(request: Request, process: str = Query(..., description="进 cam_msg = _stream_worker_ready_message(process) if cam_msg: return JSONResponse({"output": f"{stop_output}\n{cam_msg}"}, status_code=400) + sc = script_for_pm2(process) + if is_youtube_script(sc): + preflight_msg = await youtube_start_preflight_message() + if preflight_msg: + return JSONResponse({"output": f"{stop_output}\n{preflight_msg}"}, status_code=400) start_output = await control_start(process) return JSONResponse({"output": f"{stop_output}\n{start_output}"}) @app.get("/status") -async def status(process: str = Query(..., description="进程名")): +async def status( + process: str = Query(..., description="进程名"), + light: bool = Query(False, description="轻量模式:减少日志读取,适合轮询"), +): if not process_valid(process): return JSONResponse({ "raw_status": "", @@ -1059,9 +1108,11 @@ async def status(process: str = Query(..., description="进程名")): "recent_error": "" }) - full_status_raw = await control_full_status() - full_status = strip_ansi_codes(full_status_raw) - payload = await _status_payload_for_process(process) + full_status = "" + if not light: + full_status_raw = await control_full_status() + full_status = strip_ansi_codes(full_status_raw) + payload = await _status_payload_for_process(process, light=light) ps = str(payload["process_status"]) prev = _PROCESS_STATUS_CACHE.get(process) @@ -1277,8 +1328,50 @@ async def config_restore(request: Request): return {"message": msg} +def _process_monitor_family_matches(entry: dict, family: str | None) -> bool: + target = (family or "").strip().lower() + if not target or target == "all": + return True + script = str(entry.get("script") or "") + pm2 = str(entry.get("pm2") or "") + if target == "youtube": + return is_youtube_script(script) or pm2.startswith("youtube2__") + if target == "tiktok": + return is_tiktok_script(script) + return True + + +async def _process_monitor_snapshot(entry: dict, *, light: bool = True) -> dict: + pm = str(entry.get("pm2") or "").strip() + snap = await _status_payload_for_process(pm, light=light) + return { + "pm2": pm, + "script": str(entry.get("script") or ""), + "label": str(entry.get("label") or pm), + "process_status": snap.get("process_status", ""), + "business_status": snap.get("business_status", ""), + "business_note": snap.get("business_note", ""), + "is_pushing": bool(snap.get("is_pushing")), + "ffmpeg_active": snap.get("ffmpeg_active"), + "ffmpeg_speed": snap.get("ffmpeg_speed"), + "ffmpeg_frame": snap.get("ffmpeg_frame"), + "ffmpeg_time_seconds": snap.get("ffmpeg_time_seconds"), + "ffmpeg_progress_stalled": snap.get("ffmpeg_progress_stalled"), + "ffmpeg_speed_low": snap.get("ffmpeg_speed_low"), + "source_display": snap.get("source_display", ""), + "source_title": snap.get("source_title", ""), + "source_room_id": snap.get("source_room_id", ""), + "source_url": snap.get("source_url", ""), + "source_ambiguous": snap.get("source_ambiguous", False), + "source_current": snap.get("source_current", False), + } + + @app.get("/process_monitor") -async def process_monitor(): +async def process_monitor( + family: str | None = Query(None, description="按进程族过滤:youtube/tiktok"), + light: bool = Query(False, description="轻量快照:含 business_status / is_pushing"), +): ensure_default_relay_config(CONFIG_DIR) entries = [ {"pm2": p["pm2"], "script": p["script"], "label": p["label"]} @@ -1297,7 +1390,36 @@ async def process_monitor(): "label": f"YouTube {ch.get('name', cid)}", } ) - return {"entries": entries} + family_value = (family or "").strip().lower() or None + if family_value: + entries = [e for e in entries if _process_monitor_family_matches(e, family_value)] + if not light and not family_value: + return {"entries": entries} + results = await asyncio.gather( + *(_process_monitor_snapshot(e, light=bool(light)) for e in entries), + return_exceptions=True, + ) + snapshots: list[dict] = [] + for entry, result in zip(entries, results): + if isinstance(result, Exception): + snapshots.append( + { + "pm2": entry["pm2"], + "script": entry.get("script", ""), + "label": entry.get("label", entry["pm2"]), + "process_status": "error", + "business_status": "error", + "business_note": "Failed to inspect process state", + "is_pushing": False, + } + ) + else: + snapshots.append(result) + return { + "entries": snapshots, + "family": family_value or "all", + "light": bool(light), + } # ---------------- 首页 ---------------- diff --git a/web2_douyin_source_display.py b/web2_douyin_source_display.py new file mode 100644 index 0000000..fb3670a --- /dev/null +++ b/web2_douyin_source_display.py @@ -0,0 +1,215 @@ +"""Douyin source display from URL list + relay log (ported from sh2/src/douyin_source_display.py).""" + +from __future__ import annotations + +import re +from typing import Any + +_DOUYIN_LIVE_URL_RE = re.compile(r"https?://(?:www\.)?live\.douyin\.com/([a-zA-Z0-9_-]+)", re.IGNORECASE) +_DOUYIN_LIVE_ROOM_RE = re.compile(r"(?:https?://(?:www\.)?)?live\.douyin\.com/([a-zA-Z0-9_-]+)", re.IGNORECASE) +_STATUS_TITLE_RE = re.compile( + r"(?:序号\s*\d+\s*)?(.{1,80}?)\s*(?:等待直播|正在直播|直播中|已开播|开始推流|主播已下播|下播|录制中|推流中)", + re.IGNORECASE, +) +_RECORDING_TITLE_RE = re.compile(r"(?:正在录制|录制中)\s*\d*\s*个?\s*[::]\s*([^\[\r\n]{1,80})", re.IGNORECASE) +_LIVE_TITLE_RE = re.compile( + r"(?:序号\s*\d+\s*)?(.{1,80}?)\s*(?:正在直播中|正在直播|直播中|已开播|开始推流|录制中|推流中)", + re.IGNORECASE, +) +_WAIT_TITLE_RE = re.compile(r"(?:序号\s*\d+\s*)?(.{1,80}?)\s*(?:等待直播|主播已下播|下播)", re.IGNORECASE) +_LABEL_TITLE_RE = re.compile(r"(?:主播|房间名|直播间|抖音源|源站|标题)\s*[::]\s*(.{1,80})", re.IGNORECASE) +_BAD_TITLE_RE = re.compile(r"^\s*(?:-|—|_|无|未知|unknown|null|undefined|none|nan)?\s*$", re.IGNORECASE) +_ERROR_CONTEXT_RE = re.compile(r"(?:获取失败|错误|异常|error|failed|exception)", re.IGNORECASE) + + +def _clean_source_title(value: str) -> str: + text = (value or "").strip().strip("\"'`[]()()【】") + text = re.sub(r"^\[[A-Z]+\]\s*", "", text, flags=re.IGNORECASE) + text = re.sub(r"^序号\s*\d+\s*", "", text) + text = re.sub(r"^(?:主播|房间名|直播间|抖音源|源站|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) + text = _DOUYIN_LIVE_ROOM_RE.sub("", text) + text = re.sub(r"(?:等待直播|正在直播|直播中|已开播|开始推流|主播已下播|下播|录制中|推流中).*$", "", text).strip() + text = text.strip(" ,,;;||-—::") + if _BAD_TITLE_RE.match(text): + return "" + if "live.douyin.com" in text.lower(): + return "" + if re.fullmatch(r"\d{5,}", text): + return "" + if text.startswith(("监测", "格式", "错误", "无录制任务")): + return "" + return text[:48] + + +def _url_entries_from_lines(url_lines: str) -> list[dict[str, str]]: + entries: list[dict[str, str]] = [] + for line in (url_lines or "").splitlines(): + raw = line.strip() + if not raw or raw.startswith("#"): + continue + match = _DOUYIN_LIVE_ROOM_RE.search(raw) + if not match: + continue + room_id = match.group(1) + tail = raw[match.end() :].strip(" ,,;;||-—::") + title = _clean_source_title(tail) + if not title: + parts = re.split(r"[,,||]", raw, maxsplit=1) + if len(parts) > 1: + title = _clean_source_title(parts[1]) + entries.append( + { + "room_id": room_id, + "source_url": f"https://live.douyin.com/{room_id}", + "source_title": title, + } + ) + return entries + + +def _log_indicates_active_relay(recent_log: str) -> bool: + text = (recent_log or "").lower() + if "frame=" in text and "speed=" in text: + return True + return bool(_RECORDING_TITLE_RE.search(recent_log or "")) or bool(_LIVE_TITLE_RE.search(recent_log or "")) + + +def _title_from_waiting_log_only(recent_log: str) -> str: + lines = [line.strip() for line in (recent_log or "").splitlines() if line.strip()] + for line in reversed(lines[-240:]): + if _ERROR_CONTEXT_RE.search(line): + continue + match = _WAIT_TITLE_RE.search(line) or _STATUS_TITLE_RE.search(line) + if not match: + continue + title = _clean_source_title(match.group(1)) + if title: + return title + return "" + + +def _source_title_from_log(recent_log: str) -> str: + lines = [line.strip() for line in (recent_log or "").splitlines() if line.strip()] + tail = lines[-240:] + for pattern in (_RECORDING_TITLE_RE, _LIVE_TITLE_RE): + for line in reversed(tail): + match = pattern.search(line) + if not match: + continue + title = _clean_source_title(match.group(1)) + if title: + return title + for line in reversed(tail): + if _ERROR_CONTEXT_RE.search(line): + continue + match = _LABEL_TITLE_RE.search(line) + if not match: + continue + title = _clean_source_title(match.group(1)) + if title: + return title + for line in reversed(tail): + match = _WAIT_TITLE_RE.search(line) or _STATUS_TITLE_RE.search(line) + if not match: + continue + title = _clean_source_title(match.group(1)) + if title: + return title + return "" + + +def _current_room_id_from_log(recent_log: str) -> str: + room_id = "" + for match in _DOUYIN_LIVE_ROOM_RE.finditer(recent_log or ""): + room_id = match.group(1) + return room_id + + +def _normalize_title_for_match(value: str) -> str: + return re.sub(r"\s+", "", _clean_source_title(value)).casefold() + + +def _entry_for_room(entries: list[dict[str, str]], room_id: str) -> dict[str, str] | None: + if not room_id: + return None + for entry in entries: + if entry.get("room_id") == room_id: + return entry + return None + + +def _entry_for_title(entries: list[dict[str, str]], title: str) -> dict[str, str] | None: + needle = _normalize_title_for_match(title) + if not needle: + return None + for entry in entries: + candidate = _normalize_title_for_match(entry.get("source_title", "")) + if not candidate: + continue + if candidate == needle: + return entry + if len(candidate) >= 3 and len(needle) >= 3 and (candidate in needle or needle in candidate): + return entry + return None + + +def extract_douyin_source_meta(url_lines: str = "", recent_log: str = "") -> dict[str, Any]: + entries = _url_entries_from_lines(url_lines) + log_room_id = _current_room_id_from_log(recent_log) + log_title = _source_title_from_log(recent_log) + matched_entry = _entry_for_title(entries, log_title) or _entry_for_room(entries, log_room_id) + source_ambiguous = False + source_current = False + + if matched_entry: + room_id = matched_entry["room_id"] + source_url = matched_entry["source_url"] + title = log_title or matched_entry.get("source_title", "") + source_current = bool(log_room_id or log_title) + elif log_room_id: + room_id = log_room_id + source_url = f"https://live.douyin.com/{room_id}" + title = log_title + source_current = True + elif len(entries) == 1: + room_id = entries[0]["room_id"] + source_url = entries[0]["source_url"] + title = entries[0].get("source_title", "") or log_title + elif log_title: + room_id = "" + source_url = "" + title = log_title + source_ambiguous = bool(entries) + source_current = True + elif entries: + room_id = entries[0]["room_id"] + source_url = entries[0]["source_url"] + title = entries[0].get("source_title", "") + source_ambiguous = len(entries) > 1 + else: + room_id = "" + source_url = "" + title = "" + + if ( + len(entries) > 1 + and title + and not _log_indicates_active_relay(recent_log) + and title == _title_from_waiting_log_only(recent_log) + ): + pinned = entries[0] + room_id = pinned["room_id"] + source_url = pinned["source_url"] + title = pinned.get("source_title", "") or title + source_ambiguous = True + source_current = False + + display = title or (f"抖音直播间 {room_id}" if room_id else "") + return { + "source_title": title, + "source_room_id": room_id, + "source_url": source_url, + "source_display": display, + "source_ambiguous": source_ambiguous, + "source_current": source_current, + } diff --git a/web2_process_runtime.py b/web2_process_runtime.py index afb00c4..1071951 100644 --- a/web2_process_runtime.py +++ b/web2_process_runtime.py @@ -183,9 +183,6 @@ def summarize_process_activity( elif family == "youtube" and has_ffmpeg_worker and _looks_like_ffmpeg_starting(tail) and log_recent: business_status = "relay_starting" business_note = "FFmpeg 已启动,尚未观察到视频进度" - elif log_age_seconds is not None and log_age_seconds > 180: - business_status = "stale" - business_note = f"进程在线但日志 {int(log_age_seconds)} 秒未更新" elif family == "youtube" and _has_any( tail, ("等待直播", "循环等待", "检测直播间中", "没有正在监测的直播", "无录制任务", "网址内容获取失败"), @@ -198,6 +195,11 @@ def summarize_process_activity( ): business_status = "source_live" business_note = "源站已开播,但暂未检测到 FFmpeg 推流" + elif log_age_seconds is not None and log_age_seconds > 180 and ( + has_ffmpeg_worker or has_recent_ffmpeg_progress or _looks_like_ffmpeg_progress(tail) + ): + business_status = "stale" + business_note = f"进程在线但日志 {int(log_age_seconds)} 秒未更新" elif "traceback" in tail or " error " in tail or "[error]" in tail or "错误信息" in tail: business_status = "error" business_note = "近期日志中出现异常" diff --git a/web2_supervisor.py b/web2_supervisor.py index fa1a63a..25a08eb 100644 --- a/web2_supervisor.py +++ b/web2_supervisor.py @@ -10,6 +10,7 @@ import os import re import shutil import sys +import time from pathlib import Path from typing import Any, Callable, Optional @@ -26,6 +27,37 @@ def strip_ansi_codes(text: str) -> str: return re.sub(r"\x1b\[[0-9;]*m", "", text) +def _looks_like_null_device(path: str | Path | None) -> bool: + if not path: + return True + norm = str(path).replace("\\", "/").lower() + return "/dev/null" in norm or norm.endswith("/nul") or norm == "nul" + + +def _truncate_text_log(path: str | Path | None) -> None: + """对齐 sh2:每次 start 清空日志,避免旧 mtime 触发 stale/心跳过期。""" + if _looks_like_null_device(path): + return + log_path = Path(str(path)) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with log_path.open("w", encoding="utf-8", errors="ignore"): + pass + except OSError: + pass + + +def _child_env_for_process(process_name: str) -> dict[str, str]: + extra: dict[str, str] = { + "LIVE_PM2_PROCESS_NAME": process_name, + "PYTHONUNBUFFERED": "1", + "PYTHONIOENCODING": "utf-8", + } + if process_name.startswith("youtube2__"): + extra["RELAY_CHANNEL_ID"] = process_name[len("youtube2__") :] + return merge_child_env(extra) + + class BuiltinSupervisor: """用本地 PID + 日志文件管理子进程,不依赖 Node/PM2。""" @@ -107,11 +139,10 @@ class BuiltinSupervisor: out_p, err_p = self._log_paths(process_name) out_p.parent.mkdir(parents=True, exist_ok=True) - out_f = open(out_p, "ab") - err_f = open(err_p, "ab") - extra: dict[str, str] = {} - if process_name.startswith("youtube2__"): - extra["RELAY_CHANNEL_ID"] = process_name[len("youtube2__") :] + _truncate_text_log(out_p) + _truncate_text_log(err_p) + out_f = open(out_p, "a", encoding="utf-8", errors="ignore") + err_f = open(err_p, "a", encoding="utf-8", errors="ignore") try: proc = await asyncio.create_subprocess_exec( *argv, @@ -119,7 +150,7 @@ class BuiltinSupervisor: stdout=out_f, stderr=err_f, creationflags=0, - env=merge_child_env(extra), + env=_child_env_for_process(process_name), ) except Exception as e: out_f.close() @@ -134,6 +165,7 @@ class BuiltinSupervisor: "script": script, "out_log": str(out_p), "err_log": str(err_p), + "started_at": time.strftime("%Y-%m-%dT%H:%M:%S"), } self._write_state(state) return f"已启动 {script}(PID {pid}),日志见 .pm2/logs" diff --git a/web2_youtube_preflight.py b/web2_youtube_preflight.py new file mode 100644 index 0000000..50f5694 --- /dev/null +++ b/web2_youtube_preflight.py @@ -0,0 +1,20 @@ +"""YouTube 开播前网络预检(参照 sh2 network_preflight,Windows 版仅检 Google 可达)。""" + +from __future__ import annotations + +import httpx + +from web2_network import CONNECT_TIMEOUT, GOOGLE_CHECK_URL, READ_TIMEOUT, _latency_get + + +async def youtube_start_preflight_message() -> str | None: + timeout = httpx.Timeout(READ_TIMEOUT, connect=CONNECT_TIMEOUT) + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + ok, _, err = await _latency_get(client, GOOGLE_CHECK_URL) + if ok: + return None + detail = err or "timeout" + return ( + f"Google 出口不可达({detail}),YouTube 推流可能失败。" + "请先在「网络」页检测并配置代理/TUN,再开始直播。" + )