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) && (
-
- )}
- {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 (
+
+
+
+ {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,再开始直播。"
+ )