feat(runtime): 跨平台本場統計與執行中加開平台
Docs/ChangeHistory.mdDocs/RUNTIME.mdDocs/SOCKET_TRANSPORT.mdDocs/TEST_FILES_REFERENCE.mdREADME.mdRuntimeRoutes.cjsScriptLib/lifecycle/autoStop.mjsScriptLib/monitor/store.cjsScriptLib/runtime/platform-manager.mjsScriptLib/runtime/state.cjsScriptLib/tiktok/events.mjsScriptLib/twitch/client.mjsScriptLib/twitch/events.mjsServer.jsTest/auto_stop.test.mjsTest/monitor_audience_ui.cjsTest/monitor_history.test.cjsTest/platform_manager.test.mjsTest/runtime_state.test.cjsTest/runtime_ui.cjsTest/twitch_events.test.mjsTikTok.jsassets/app.css.build.jsonassets/monitor.jsassets/runtime-start.jslang/en.jsonlang/zh-TW.jsonmonitor.htmlruntime.html
commit e30add1d91c64966bff6329355f31048cce39ceaAuthor: Nuclear0709 <agp05432@gmail.com>Date: Fri Nov 13 14:13:59 2026 +0800 feat(runtime): 跨平台本場統計與執行中加開平台 monitor 場次統計改為跨平台共用並支援歷史顯示;Twitch 補 follow/cheer/訂閱事件與 scope。runtime 新增 POST /api/runtime/platforms 與 IPC ADD_PLATFORM,執行中可加開平台而不重啟,保留同一個 monitor 場次與統計。 diff --git a/Docs/ChangeHistory.md b/Docs/ChangeHistory.md--- a/Docs/ChangeHistory.md+++ b/Docs/ChangeHistory.md@@ -1,5 +1,91 @@ # ChangeHistory +## 2026.11.13 12:40 執行中加開平台++**類型**: 功能 · **檔案**: `TikTok.js`、`ScriptLib/runtime/platform-manager.mjs`、`ScriptLib/runtime/state.cjs`、`ScriptLib/lifecycle/autoStop.mjs`、`RuntimeRoutes.cjs`、`Server.js`、`runtime.html`、`assets/runtime-start.js`、`lang/*.json`、`Test/runtime_state.test.cjs`、`Test/runtime_ui.cjs`、`Test/platform_manager.test.mjs`、`Test/auto_stop.test.mjs`、`Docs/RUNTIME.md`、`Docs/TEST_FILES_REFERENCE.md`++### 修改++- 新增 `POST /api/runtime/platforms` 與「執行中加開平台」按鈕;以 IPC `ADD_PLATFORM` 通知既有子程序,不重啟程序。+- 平台啟動抽為 `startPlatform()`;同一平台只啟動一次,失敗回報 `PLATFORM_STATE` 並允許重試。+- RuntimeState 保存 `platformStates`/`platformErrors`;子程序回報狀態時更新。+- `viewerCounts` 的平台 include 改為讀取動態旗標,加開後立即納入合併觀眾數。+- AutoStop 支援 `addPlatform()`;Kick/Odysee/YouTube 加開後加入 blocked 並取消既有倒數。+- Runtime UI 在執行中顯示可加開平台按鈕;i18n 與測試同步。++### 限制++- 只支援加開,不支援執行中移除單一平台;移除仍需停止整個子程序。+- 加開 Twitch 不會事後建立 AutoClip;AutoClip 仍以啟動時已啟用 Twitch 為準。+- TikTok browser/manual 模式加開只開啟對應來源,不建立第二個原生連線;來源模式不再受啟動時 `isTK` 影響,避免先開 Twitch、之後才加開 TikTok manual 時誤走原生連線。++### 驗證++- `node --test Test/runtime_state.test.cjs Test/platform_manager.test.mjs Test/auto_stop.test.mjs`+- `node Test/runtime_ui.cjs`+- `npm run lint`、`npm run check:css`++---++## 2026.11.13 04:12 monitor 場次統計與 Twitch 訂閱++**類型**: 功能 · **檔案**: `TikTok.js`、`ScriptLib/tiktok/events.mjs`、`ScriptLib/twitch/events.mjs`、`ScriptLib/twitch/client.mjs`、`ScriptLib/monitor/store.cjs`、`monitor.html`、`assets/monitor.js`、`lang/*.json`、`Test/monitor_history.test.cjs`、`Test/monitor_audience_ui.cjs`、`Test/twitch_events.test.mjs`、`Docs/SOCKET_TRANSPORT.md`、`README.md`++### 修改++- 本場統計改為所有平台共用:`joins`、`likes`、`chats`、`follows`、`subscriptions`、`gifts`、`peakViewers`。+- monitor 工具列新增人數、關注、訂閱、送禮統計;歷史模式切換場次時同步顯示該場統計。+- `monitor_sessions` 新增欄位並自動遷移既有資料庫;峰值人數取 `session.peakViewers` 與訊息 `userNum` 較大者。+- Twitch follow 改送 `follow` 分類、cheer 改送 `gift` 分類,新增 `onChannelSubscription` 處理訂閱/贈訂並送 `subscribe` 分類。+- Twitch 必要 scope 加入 `channel:read:subscriptions`。+- 補場次統計、歷史 UI 與 Twitch 事件測試。++### 限制++- 訂閱事件依 Twitch EventSub 與 token scope;舊 token 需重新授權。+- 歷史統計只從新版子程序開始累計;舊場次沒有新增欄位時顯示 0。++### 驗證++- `node --test Test/monitor_history.test.cjs Test/twitch_events.test.mjs`+- `node Test/monitor_audience_ui.cjs`+- `npm run lint`、`npm run check:css`++---++## 2026.11.13 03:33 常客手動合併與別名++**類型**: 功能 · **檔案**: `ScriptLib/audience/tracker.cjs`、`AudienceRoutes.cjs`、`audience.html`、`assets/audience.js`、`assets/audience.css`、`lang/*.json`、`Test/audience_tracker.test.cjs`、`Test/audience_routes.test.cjs`、`Test/audience_ui.cjs`、`Docs/AUDIENCE.md`、`Docs/TEST_FILES_REFERENCE.md`++### 問題++同一人可能因改名、或一次帶 userId、一次只帶名稱而被記成兩個 key;自動猜測容易誤併不同人。++### 修改++- 常客明細新增「合併資料」,只允許手動操作。+- `GET /api/audience/merge/preview` 先顯示來源、目標、合併後統計、共同場次與別名關係,不寫入。+- `POST /api/audience/merge` 才套用;同平台限制、預覽使用 savepoint/rollback。+- `audience_aliases` 保存舊 key → target;之後舊名稱的活動仍累計到目標。+- 合併時 first_seen 取較早、last_seen 取較晚;共同場次訊息/送禮套用單場上限、進房不重複。+- 顯示名稱保留目標;不自動合併、不做相似名稱猜測。+- 補 tracker/route/UI 測試。++### 限制++- 合併選單標示 displayName/userKey/分數,選取後顯示完整 userKey、場次與訊息數;避免同名不同身分誤選。+- 新增「還原最近合併」:batch 快照可還原來源/目標 preimage、alias 與統計。+- 合併後有新活動、或之後又合併到其他身分時拒絕還原;只保留 90 天 snapshot。+- 只合併同平台身分;沒有任意歷史時間點還原。++### 驗證++- `node --test Test/audience_tracker.test.cjs Test/audience_routes.test.cjs`+- `node Test/audience_ui.cjs`+- `npm run lint`、`npm run check:css`++---+ ## 2026.11.11 01:15 MediaCache URL 補圖片副檔名 **類型**: 修正 · **檔案**: `ScriptLib/media/cache.cjs`、`MediaRoutes.cjs`、`ScriptLib/monitor/store.cjs`、`OtherTool/live_engine/core/emoji_parser.py`、`Test/media_cache.test.cjs`、`OtherTool/live_engine/tests/test_emoji_parser.py`、`Docs/MEDIA_CACHE.md`、`Docs/RUNTIME.md`diff --git a/Docs/RUNTIME.md b/Docs/RUNTIME.md--- a/Docs/RUNTIME.md+++ b/Docs/RUNTIME.md@@ -15,7 +15,7 @@ /runtime 與 /api/runtime 沿用原啟停/日誌端點的公開存取範圍,只回傳程序摘要,不提供環境變數、完整啟動 query 或日誌。 原 /status 純文字及 /status/stream 日誌介面保持相容;需要純文字的外部工具可改查 /status。 -驗證:node --test Test/runtime_state.test.cjs;測試使用假程序,不啟動直播或通知。`node Test/runtime_ui.cjs` 使用 mock API 檢查來源卡、toast、390px 寬度並輸出 `output/playwright/runtime-desktop.png`、`runtime-mobile.png`(需 Chrome/Edge,不在 `test:ci`)。+驗證:node --test Test/runtime_state.test.cjs Test/platform_manager.test.mjs Test/auto_stop.test.mjs;測試使用假程序,不啟動直播或通知。`node Test/runtime_ui.cjs` 使用 mock API 檢查來源卡、加開平台、toast、390px 寬度並輸出 `output/playwright/runtime-desktop.png`、`runtime-mobile.png`(需 Chrome/Edge,不在 `test:ci`)。 ## 可視化啟動 @@ -23,6 +23,17 @@ 按「啟動服務」透過 POST /api/runtime/start 送出 JSON,原頁顯示程序狀態。執行中或停止中鎖定選單;跨分頁重複啟動會回傳 409。GET /api/runtime/options 提供後端白名單。JSON 啟動要求必須來自相同 Origin。原 /open query 入口維持相容,共用同一程序啟動函數。 +### 執行中加開平台++程序執行中可在「執行中加開平台」按鈕加開尚未啟用的平台,不需停止再啟動,monitor 場次、本場統計與觀眾數會延續。流程是 `POST /api/runtime/platforms`(JSON `{ "platform": "tiktok" | "twitch" | "kick" | "youtube" | "odysee" }`)→ 主服務以 IPC `ADD_PLATFORM` 通知既有子程序 → 子程序只啟動該平台並回報 `PLATFORM_STATE`。++- 同一平台在本次執行中只啟動一次;重複請求回 409 `PLATFORM_ALREADY_RUNNING`。+- 加開失敗會保留平台狀態與錯誤,允許再次嘗試;失敗不會中斷既有平台。+- TikTok browser/manual 模式加開時仍由既有來源設定決定是否開啟專用瀏覽器;不建立第二個原生連線。+- Kick、Odysee、YouTube 沒有可靠的直播結束訊號,加開後會停用自動停止,避免誤停既有平台。+- 目前只支援「加開」,不支援執行中移除單一平台;需要移除時仍停止整個子程序。+- 加開 Twitch 時 AutoClip 不會在事後補建;AutoClip 仍以啟動時是否已啟用 Twitch 為準。+ Socket 使用既有 SOCKET_API 設定(可再用 SOCKET_APIS 加開多個端點,每端點可用 `?filter=類別` 只收指定分類);Bark 與 ntfy 使用既有通知設定(BARK_API/NTFY_API)。「開啟設定」只導向設定頁。程序啟動成功不代表各平台已完成登入。 ## 直播訊息監控面板diff --git a/Docs/SOCKET_TRANSPORT.md b/Docs/SOCKET_TRANSPORT.md--- a/Docs/SOCKET_TRANSPORT.md+++ b/Docs/SOCKET_TRANSPORT.md@@ -8,7 +8,7 @@ 測試派發可分別關閉 socket/通知;`Notifier.notify` 回傳 `[{ channel, ok }]`,子程序再彙整成 `sent`/`partial`/`failed`/`nochannels`。一般聊天仍同時走 socket 與通知,不受測試選項影響。 -TikTok 的 `sendCat` 會在 payload 附上 `session: { joins, likes, chats }`,供 monitor 顯示本場累計;一般 socket 端點可忽略此欄位。點讚通知以 3 秒窗口合併,monitor 的逐筆點讚列不受影響。+所有平台的 StreamMessage 會附上 `session: { joins, likes, chats, follows, subscriptions, gifts, peakViewers }`,供 monitor 顯示本場累計;一般 socket 端點可忽略此欄位。`peakViewers` 是合併觀眾數的場次峰值,不是目前人數。點讚通知以 3 秒窗口合併,monitor 的逐筆點讚列不受影響。 通訊格式維持 UTF-8 JSON,每筆以換行結尾,不需修改既有接收端協定。 ## 預設限制diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.md--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -55,10 +55,15 @@ node Test/xxx.mjs | 檔案 | 測試內容 | | ------ | --------- | | `Test/config_login_ui.cjs` | 設定/登入頁深色主題、安全閘門、24 個欄位、桌面側欄/手機導覽、390px 無溢出、未儲存狀態、登入錯誤與成功;輸出 `output/playwright/config-desktop.png`、`config-mobile.png`、`login-desktop.png` |-| `Test/runtime_ui.cjs` | 運行狀態頁的狀態卡、聊天來源 badge、manual/browser 顯示、重新檢查/停止 toast、390px 無溢出;輸出 `runtime-desktop.png`、`runtime-mobile.png` |-| `Test/audience_ui.cjs` | 常客關係 badge/avatar、關係篩選、總頁數、分數組成、等級門檻、最近場次與手機寬度 |-| `Test/monitor_audience_ui.cjs` | 監控聊天列、本場統計、測試訊息、Socket/通知派發、精確結果與即時/歷史切換 |-| `Test/monitor_history.test.cjs` | 監控歷史 SQLite、data URI materialize 與保留/釋放 |+| `Test/runtime_ui.cjs` | 運行狀態頁的狀態卡、聊天來源 badge、manual/browser 顯示、執行中加開平台、重新檢查/停止 toast、390px 無溢出;輸出 `runtime-desktop.png`、`runtime-mobile.png` |+| `Test/runtime_state.test.cjs` | 程序生命週期、停止逾時、啟動白名單、執行中加開平台 API 與 runtime 狀態 |+| `Test/platform_manager.test.mjs` | 平台啟動冪等、失敗重試與狀態回報 |+| `Test/auto_stop.test.mjs` | 自動停止倒數、blocked 平台與執行中加開平台 |+| `Test/audience_tracker.test.cjs` | 常客分數、等級、上限、userId 優先、關係篩選、手動合併預覽與別名 |+| `Test/audience_routes.test.cjs` | 常客列表/明細 API、平台正規化、合併預覽與套用 |+| `Test/audience_ui.cjs` | 常客關係 badge/avatar、關係篩選、總頁數、明細、合併預覽/套用/還原與手機寬度 |+| `Test/monitor_audience_ui.cjs` | 監控聊天列、本場/歷史統計、測試訊息、Socket/通知派發、精確結果與即時/歷史切換 |+| `Test/monitor_history.test.cjs` | 監控歷史 SQLite、場次跨平台統計、data URI materialize 與保留/釋放 | | `Test/monitor_routes.test.cjs` | 監控歷史 API 的登入、場次、訊息與缺少場次 | | `Test/navigation_guard.test.mjs` | Signer TikTok 導航最小間隔與 WAF 冷卻 | | `Test/video_tk_ui.cjs` | videoTK 劇院樣式還原、可拖動按鈕與拖曳不誤觸 |diff --git a/README.md b/README.md--- a/README.md+++ b/README.md@@ -263,7 +263,7 @@ GIFT_TRANSLATE_PREFILL_LIMIT=10 ### 4. Twitch OAuth Scope 自動檢查與重新授權 -啟動 Twitch 模式時,系統會自動檢查 `tokens.json` 內的 scope 是否包含 `user:read:subscriptions`(用於檢查聊天室的訂閱者身份)。+啟動 Twitch 模式時,系統會自動檢查 `tokens.json` 內的 scope 是否包含 `user:read:subscriptions`(檢查聊天室訂閱者身份)與 `channel:read:subscriptions`(接收訂閱/贈訂 EventSub 事件)。 若缺少 scope,系統會: diff --git a/RuntimeRoutes.cjs b/RuntimeRoutes.cjs--- a/RuntimeRoutes.cjs+++ b/RuntimeRoutes.cjs@@ -25,9 +25,32 @@ function sendRuntimePage(res) { res.end(error ? '無法載入運行狀態頁' : html); }); }-function serveRuntime(req, res, runtime, start) {+function serveRuntime(req, res, runtime, start, addPlatform = null) { const pathname = req.url.split('?')[0];- if (!['/runtime', '/api/runtime', '/api/runtime/options', '/api/runtime/start'].includes(pathname)) return false;+ if (!['/runtime', '/api/runtime', '/api/runtime/options', '/api/runtime/start',+ '/api/runtime/platforms'].includes(pathname)) return false;+ if (pathname === '/api/runtime/platforms') {+ if (req.method !== 'POST') { res.writeHead(405); res.end(); return true; }+ const origin = (req.socket?.encrypted ? 'https://' : 'http://') + req.headers.host;+ if (req.headers.origin !== origin) { sendJson(res, 403, { code: 'ORIGIN_DENIED', error: '請由主服務管理頁操作' }); return true; }+ void (async () => {+ try {+ const body = await readJson(req, 4096);+ if (!body || typeof body !== 'object' || Array.isArray(body)) {+ sendJson(res, 400, { code: 'INVALID_PLATFORM', error: '平台參數無效' });+ return;+ }+ if (!addPlatform) {+ sendJson(res, 409, { code: 'RUNTIME_UNAVAILABLE', error: '目前沒有可加開的子程序' });+ return;+ }+ sendJson(res, 202, addPlatform(body.platform));+ } catch (error) {+ sendJson(res, error.status || 400, { code: error.code || 'PLATFORM_ADD_FAILED', error: error.message });+ }+ })();+ return true;+ } if (pathname === '/api/runtime/start') { if (req.method !== 'POST') { res.writeHead(405); res.end(); return true; } const origin = (req.socket?.encrypted ? 'https://' : 'http://') + req.headers.host;diff --git a/ScriptLib/lifecycle/autoStop.mjs b/ScriptLib/lifecycle/autoStop.mjs--- a/ScriptLib/lifecycle/autoStop.mjs+++ b/ScriptLib/lifecycle/autoStop.mjs@@ -27,10 +27,11 @@ export function createAutoStop({ clearTimeout: cancel = clearTimeout } = {}) { const states = new Map(platforms.map(platform => [platform, 'live']));+ const blockedSet = new Set(blocked); let timer = null; let stopped = false; let sawLive = false;- const active = enabled && platforms.length > 0 && blocked.length === 0;+ let active = enabled && platforms.length > 0 && blockedSet.size === 0; if (enabled && blocked.length > 0) { log(`自動停止未啟用:${blocked.join('、')} 沒有可靠的結束訊號`);@@ -51,7 +52,7 @@ export function createAutoStop({ log(`所有平台已結束,${seconds} 秒後自動停止`); timer = schedule(() => { timer = null;- if (stopped || !allEnded()) return;+ if (!active || stopped || !allEnded()) return; stopped = true; log('自動停止程序'); onStop();@@ -59,6 +60,19 @@ export function createAutoStop({ }; return {+ /** 執行中加開平台:blocked 平台會停用自動停止,避免沒有結束訊號時誤停。 */+ addPlatform(platform, { blocked = false } = {}) {+ if (!platform || states.has(platform)) return false;+ states.set(platform, 'live');+ if (blocked) blockedSet.add(platform);+ else platforms.push(platform);+ active = enabled && platforms.length > 0 && blockedSet.size === 0;+ if (blocked) {+ clear(`${platform} 沒有可靠的結束訊號`);+ log(`自動停止未啟用:${[...blockedSet].join('、')} 沒有可靠的結束訊號`);+ }+ return true;+ }, /** 平台恢復直播時取消倒數。 */ markLive(platform) { if (!states.has(platform)) return;diff --git a/ScriptLib/monitor/store.cjs b/ScriptLib/monitor/store.cjs--- a/ScriptLib/monitor/store.cjs+++ b/ScriptLib/monitor/store.cjs@@ -44,7 +44,11 @@ class MonitorHistoryStore { message_count INTEGER NOT NULL DEFAULT 0, joins INTEGER NOT NULL DEFAULT 0, likes INTEGER NOT NULL DEFAULT 0,- chats INTEGER NOT NULL DEFAULT 0+ chats INTEGER NOT NULL DEFAULT 0,+ follows INTEGER NOT NULL DEFAULT 0,+ subscriptions INTEGER NOT NULL DEFAULT 0,+ gifts INTEGER NOT NULL DEFAULT 0,+ peak_viewers INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS messages ( id INTEGER PRIMARY KEY AUTOINCREMENT,@@ -60,6 +64,18 @@ class MonitorHistoryStore { ); CREATE INDEX IF NOT EXISTS messages_session_t ON messages(session_id, t, id); CREATE INDEX IF NOT EXISTS messages_t ON messages(t);+ `);+ // 既有資料庫補上新統計欄位。+ const sessionColumns = new Set(this.db.prepare('PRAGMA table_info(sessions)').all().map(row => row.name));+ for (const [name, ddl] of [+ ['follows', 'INTEGER NOT NULL DEFAULT 0'],+ ['subscriptions', 'INTEGER NOT NULL DEFAULT 0'],+ ['gifts', 'INTEGER NOT NULL DEFAULT 0'],+ ['peak_viewers', 'INTEGER NOT NULL DEFAULT 0']+ ]) {+ if (!sessionColumns.has(name)) this.db.exec(`ALTER TABLE sessions ADD COLUMN ${name} ${ddl}`);+ }+ this.db.exec(` CREATE TABLE IF NOT EXISTS message_media ( message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE, hash TEXT NOT NULL,@@ -182,10 +198,16 @@ class MonitorHistoryStore { const messageId = Number(this.db.prepare('SELECT last_insert_rowid() AS id').get().id); const insertMedia = this.db.prepare('INSERT OR IGNORE INTO message_media(message_id,hash) VALUES(?,?)'); for (const hash of prepared.hashes) insertMedia.run(messageId, hash);+ const peakViewers = Math.max(Number(stats.peakViewers) || 0, Number(entry.userNum) || 0); this.db.prepare(`UPDATE sessions SET last_message_at=?, message_count=message_count+1,- joins=MAX(joins, ?), likes=MAX(likes, ?), chats=MAX(chats, ?)- WHERE id=?`).run(t, Number(stats.joins) || 0, Number(stats.likes) || 0, Number(stats.chats) || 0, sessionId);+ joins=MAX(joins, ?), likes=MAX(likes, ?), chats=MAX(chats, ?),+ follows=MAX(follows, ?), subscriptions=MAX(subscriptions, ?),+ gifts=MAX(gifts, ?), peak_viewers=MAX(peak_viewers, ?)+ WHERE id=?`).run(t,+ Number(stats.joins) || 0, Number(stats.likes) || 0, Number(stats.chats) || 0,+ Number(stats.follows) || 0, Number(stats.subscriptions) || 0,+ Number(stats.gifts) || 0, peakViewers, sessionId); this.db.exec('COMMIT'); } catch (error) { try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }@@ -237,6 +259,10 @@ class MonitorHistoryStore { joins: row.joins, likes: row.likes, chats: row.chats,+ follows: row.follows || 0,+ subscriptions: row.subscriptions || 0,+ gifts: row.gifts || 0,+ peakViewers: row.peak_viewers || 0, platforms }; }diff --git a/ScriptLib/runtime/platform-manager.mjs b/ScriptLib/runtime/platform-manager.mjs--- /dev/null+++ b/ScriptLib/runtime/platform-manager.mjs@@ -0,0 +1,47 @@+/**+ * 執行中平台啟動器:同一平台只啟動一次,狀態經 callback 回報給主服務。+ * 平台啟動邏輯仍由呼叫端注入,管理器只負責冪等、狀態與失敗重試。+ */++/**+ * @param {Object} options+ * @param {Record<string, () => Promise<string>|string>} options.starts 平台名稱 → 啟動函式。+ * @param {(platform: string, state: string, error?: string) => void} [options.report]+ * @param {(platform: string) => void} [options.onStart] 啟動前設定平台旗標。+ * @param {(message: string) => void} [options.log]+ * @returns {{add: (platform: string) => Promise<{ok: boolean, state: string, reason?: string}>, has: (platform: string) => boolean, list: () => string[]}}+ */+export function createPlatformManager({ starts, report = () => {}, onStart = () => {}, log = () => {} } = {}) {+ const started = new Set();++ async function add(platform) {+ const name = String(platform || '').trim().toLowerCase();+ if (!starts || typeof starts[name] !== 'function') {+ report(name, 'failed', '不支援的平台');+ return { ok: false, state: 'failed', reason: 'unsupported' };+ }+ if (started.has(name)) {+ report(name, 'running');+ return { ok: false, state: 'running', reason: 'already-running' };+ }+ started.add(name);+ onStart(name);+ report(name, 'starting');+ try {+ const state = await starts[name]();+ report(name, state || 'running');+ return { ok: true, state: state || 'running' };+ } catch (error) {+ started.delete(name);+ report(name, 'failed', error.message || String(error));+ log(`[PLATFORM] ${name} 啟動失敗: ${error.message || error}`);+ throw error;+ }+ }++ return {+ add,+ has: platform => started.has(String(platform || '').trim().toLowerCase()),+ list: () => [...started]+ };+}diff --git a/ScriptLib/runtime/state.cjs b/ScriptLib/runtime/state.cjs--- a/ScriptLib/runtime/state.cjs+++ b/ScriptLib/runtime/state.cjs@@ -6,7 +6,10 @@ class RuntimeState { this.logger = options.logger || null; this._stopTimer = null; this.child = null;- this.data = { state: 'stopped', pid: null, startedAt: null, stoppedAt: null, stopRequestedAt: null, exitCode: null, signal: null, error: null, platforms: [] };+ this.data = {+ state: 'stopped', pid: null, startedAt: null, stoppedAt: null, stopRequestedAt: null,+ exitCode: null, signal: null, error: null, platforms: [], platformStates: {}+ }; } _log(...parts) { if (this.logger) this.logger(...parts);@@ -25,7 +28,11 @@ class RuntimeState { if (!Number.isFinite(message.cpuPercent) || message.cpuPercent < 0 || !Number.isFinite(message.rssBytes) || message.rssBytes < 0) return; this.resources = { cpuPercent: message.cpuPercent, rssBytes: message.rssBytes, sampledAt: this.now() }; });- this.data = { state: 'starting', pid: null, startedAt: null, stoppedAt: null, stopRequestedAt: null, exitCode: null, signal: null, error: null, platforms: [...platforms] };+ this.data = {+ state: 'starting', pid: null, startedAt: null, stoppedAt: null, stopRequestedAt: null,+ exitCode: null, signal: null, error: null, platforms: [...platforms],+ platformStates: Object.fromEntries(platforms.map(platform => [platform, 'starting']))+ }; child.once('spawn', () => { if (this.child !== child) return; this.data.pid = child.pid;@@ -48,6 +55,38 @@ class RuntimeState { this.child = null; }); }+ /** 執行中加開平台;重複平台不重複加入。 */+ addPlatform(platform) {+ const name = String(platform || '').trim();+ if (!name) return false;+ if (!this.data.platforms.includes(name)) this.data.platforms.push(name);+ this.data.platformStates[name] = 'starting';+ return true;+ }++ /** 移除平台(只用於失敗後重試;不影響其他平台)。 */+ removePlatform(platform) {+ const name = String(platform || '').trim();+ if (!name) return;+ this.data.platforms = this.data.platforms.filter(item => item !== name);+ delete (this.data.platformStates || {})[name];+ if (this.data.platformErrors) delete this.data.platformErrors[name];+ }++ /** 子程序回報的平台狀態;未知平台也保留,方便顯示失敗原因。 */+ setPlatformState(platform, state, error = null) {+ const name = String(platform || '').trim();+ if (!name) return;+ if (!this.data.platforms.includes(name)) this.data.platforms.push(name);+ this.data.platformStates[name] = state || 'unknown';+ if (error) this.data.platformErrors = { ...(this.data.platformErrors || {}), [name]: error };+ else if (this.data.platformErrors?.[name]) {+ const next = { ...this.data.platformErrors };+ delete next[name];+ this.data.platformErrors = next;+ }+ }+ clearStopTimer() { if (this._stopTimer) { clearTimeout(this._stopTimer);@@ -107,7 +146,7 @@ class RuntimeState { * @returns {Object} */ snapshot() {- return { ...this.data, resources: this.child && ['running', 'stopping'].includes(this.data.state) && this.resources && this.now() - this.resources.sampledAt <= 10000 ? { ...this.resources } : null, platforms: [...this.data.platforms],+ return { ...this.data, resources: this.child && ['running', 'stopping'].includes(this.data.state) && this.resources && this.now() - this.resources.sampledAt <= 10000 ? { ...this.resources } : null, platforms: [...this.data.platforms], platformStates: { ...(this.data.platformStates || {}) }, platformErrors: { ...(this.data.platformErrors || {}) }, uptimeMs: this.data.startedAt === null ? 0 : Math.max(0, (this.data.stoppedAt ?? this.now()) - this.data.startedAt), stopDelayed: this.data.state === 'stopping' && this.now() - this.data.stopRequestedAt >= 15000 }; }diff --git a/ScriptLib/tiktok/events.mjs b/ScriptLib/tiktok/events.mjs--- a/ScriptLib/tiktok/events.mjs+++ b/ScriptLib/tiktok/events.mjs@@ -41,8 +41,11 @@ export function registerTikTokEvents(connection, deps) { } = deps; // 本場累計;隨每則 socket payload 帶給 monitor。- const sessionStats = { joins: 0, likes: 0, chats: 0 };+ const sessionStats = deps.sessionStats || {+ joins: 0, likes: 0, chats: 0, follows: 0, subscriptions: 0, gifts: 0, peakViewers: 0+ }; const sessionSnapshot = () => ({ ...sessionStats });+ const tiktokEnabled = () => (typeof isTK === 'function' ? isTK() : isTK === true); const handlerErrorLog = new Map(); function describeException(value) {@@ -335,6 +338,7 @@ export function registerTikTokEvents(connection, deps) { let iconn = getTikTokProfilePic(data.user); logger.log(data.user.nickname, "關注了主播"); + sessionStats.follows += 1; notify(data.user.nickname, "關注了主播", iconn); sendCat('follow', data.user.nickname, "關注了主播", iconn, "", false); dedup.add(data.user.nickname.trim(), "關注了主播");@@ -343,7 +347,7 @@ export function registerTikTokEvents(connection, deps) { safeEvent(WebcastEvent.CHAT, data => { if (!data?.user?.nickname) return; reportTraffic({ platform: 'TikTok', eventType: 'chat', id: data.msgId || data.common?.msgId, sentAt: data.createTime || data.common?.createTime, userId: data.user?.userId || data.user?.id, user: data.user?.nickname, message: data.comment });- if (isTK) {+ if (tiktokEnabled()) { const result = tikTokChatGuard.check(data); if (!result.accepted) { tikTokReplayDropped++;@@ -520,6 +524,7 @@ export function registerTikTokEvents(connection, deps) { // 連擊結束 / 非連擊禮物 → 送出最終通知 let mess = `送出了 ${giftNameForDisplay} ${data.repeatCount} 個`; logger.log(`送出了 ${data.user.nickname} : ${giftNameForDisplay} ${data.repeatCount} 個`);+ sessionStats.gifts += 1; notify(data.user.nickname, mess, giftImg); sendCat('gift', data.user.nickname, mess, iconn, giftImg, true);diff --git a/ScriptLib/twitch/client.mjs b/ScriptLib/twitch/client.mjs--- a/ScriptLib/twitch/client.mjs+++ b/ScriptLib/twitch/client.mjs@@ -8,7 +8,7 @@ // writeLog(file, message, type), // onError(err), // onStreamOnline(event), onStreamOffline(event),-// onFollow(event), onCheer(event), onChat(event),+// onFollow(event), onCheer(event), onSubscription(event), onChat(event), // onSocketDisconnect(), // onViewer(stream|null), // }@@ -20,7 +20,7 @@ import { ApiClient } from '@twurple/api'; import { EventSubWsListener } from '@twurple/eventsub-ws'; const tokenPath = path.resolve('./tokens.json');-const REQUIRED_TWITCH_SCOPES = ['user:read:subscriptions'];+const REQUIRED_TWITCH_SCOPES = ['user:read:subscriptions', 'channel:read:subscriptions']; const emptyTokenTemplate = { accessToken: "",@@ -191,6 +191,7 @@ function registerHandlers(deps) { listener.onStreamOffline(tuser, async event => deps.onStreamOffline?.(event)); listener.onChannelFollow(tuser, tuser, async event => deps.onFollow?.(event)); listener.onChannelCheer(tuser, tuser, async event => deps.onCheer?.(event));+ listener.onChannelSubscription(tuser, tuser, async event => deps.onSubscription?.(event)); listener.onChannelChatMessage(tuser, tuser, async event => deps.onChat?.(event)); listener.onUserSocketDisconnect?.(() => deps.onSocketDisconnect?.()); }diff --git a/ScriptLib/twitch/events.mjs b/ScriptLib/twitch/events.mjs--- a/ScriptLib/twitch/events.mjs+++ b/ScriptLib/twitch/events.mjs@@ -12,7 +12,8 @@ * @property {(file: string, message: string, type?: string) => void} writeLog 寫入日誌檔。 * @property {(title: string, body: string, icon?: string, url?: string) => Promise<void>} notify 推播通知。 * @property {(text: string) => void} sendSystem 送出一則系統訊息到 socket。- * @property {(user: string, text: string, icon: string) => void} sendNotice 送出一則使用者訊息到 socket。+ * @property {(user: string, text: string, icon: string, category?: string) => void} sendNotice 送出一則使用者訊息到 socket。+ * @property {{follows:number, subscriptions:number, gifts:number}} sessionStats 本場累計。 * @property {(count: number) => number} setViewerCount 更新 Twitch 觀眾數並回傳套用後的有效值(無效值會被忽略)。 * @property {() => void} resetViewerCount 明確離線時歸零。 * @property {() => void} updateCombinedViewerCount 重算合併觀眾數並送出。@@ -107,12 +108,13 @@ export async function onStreamOffline(event, deps) { export async function onFollow(event, deps) { const icon = await deps.getUserIcon(event.userId); const message = `關注了主播`;+ deps.sessionStats.follows += 1; deps.logger.log(message); await deps.notify(event.userDisplayName, "關注了主播", icon); - deps.sendNotice(event.userDisplayName, message, icon);+ deps.sendNotice(event.userDisplayName, message, icon, 'follow'); deps.writeLog("Default", `${event.userDisplayName} ${message}`, "Twitch Follow"); }@@ -126,11 +128,32 @@ export async function onFollow(event, deps) { export async function onCheer(event, deps) { const message = `送出 ${event.bits} 小奇點`; const icon = await deps.getUserIcon(event.userId);+ deps.sessionStats.gifts += 1; deps.logger.log(`${event.userDisplayName} ${message}`); await deps.notify(event.userDisplayName, message, icon);- deps.sendNotice(event.userDisplayName, message, icon);+ deps.sendNotice(event.userDisplayName, message, icon, 'gift'); deps.writeLog("Default", `${event.userDisplayName} ${message}`, "Twitch Cheer"); }++/**+ * 訂閱/贈訂事件;計入本場 subscriptions 並送 subscribe 分類訊息。+ * @param {{userId: string, userDisplayName: string, tier?: string, isGift?: boolean}} event+ * @param {TwitchEventDeps} deps+ * @returns {Promise<void>}+ */+export async function onSubscription(event, deps) {+ const name = event.userDisplayName || event.userName || '匿名';+ const tier = event.tier ? `Tier ${Math.max(1, Math.round(Number(event.tier) / 1000) || 1)}` : '訂閱';+ const message = event.isGift ? `收到贈訂(${tier})` : `訂閱了(${tier})`;+ const icon = event.userId ? await deps.getUserIcon(event.userId).catch(() => '') : '';+ deps.sessionStats.subscriptions += 1;++ deps.logger.log(`${name} ${message}`);+ await deps.notify(name, message, icon);+ deps.sendNotice(name, message, icon, 'subscribe');++ deps.writeLog("Default", `${name} ${message}`, "Twitch Subscription");+}diff --git a/Server.js b/Server.js--- a/Server.js+++ b/Server.js@@ -523,6 +523,9 @@ function startRuntime(url) { if (event?.type === 'TRAFFIC_EVENT') require('./TrafficRoutes.cjs').traffic.record(event.data); else if (event?.type === 'STREAM_MESSAGE') pushStreamMessage(event.data); else if (event?.type === 'TEST_RESULT') pushTestResult(event.data);+ else if (event?.type === 'PLATFORM_STATE' && event.data?.platform) {+ runtimeState.setPlatformState(event.data.platform, event.data.state, event.data.error || null);+ } }); runtimeState.attach(launchedProcess, activePlatforms); runtimeState.data.features = { isSocket, isBark, isNtfy };@@ -643,13 +646,49 @@ function startRuntime(url) { return runtimeState.snapshot(); } +/** 執行中加開平台;不重啟子程序,monitor 場次與統計保持連續。 */+function addRuntimePlatform(platform) {+ const name = String(platform || '').trim().toLowerCase();+ const allowed = new Set(['tiktok', 'twitch', 'kick', 'youtube', 'odysee']);+ if (!allowed.has(name)) {+ throw Object.assign(new Error('不支援的平台'), { status: 400, code: 'INVALID_PLATFORM' });+ }+ if (!tiktokProcess || tiktokProcess.connected === false) {+ throw Object.assign(new Error('目前沒有執行中的聊天子程序'), { status: 409, code: 'RUNTIME_STOPPED' });+ }+ const current = runtimeState.snapshot();+ if ((current.platforms || []).includes(name)) {+ if (current.platformStates?.[name] !== 'failed') {+ throw Object.assign(new Error(`${name} 已在本次執行中`), { status: 409, code: 'PLATFORM_ALREADY_RUNNING' });+ }+ // 上次加開失敗:允許重新嘗試,子程序端也已清除啟動狀態。+ runtimeState.removePlatform(name);+ }+ runtimeState.addPlatform(name);+ try {+ tiktokProcess.send({ type: 'ADD_PLATFORM', platform: name });+ } catch (error) {+ runtimeState.setPlatformState(name, 'failed', error.message);+ throw Object.assign(new Error(`無法通知子程序:${error.message}`), { status: 500, code: 'PLATFORM_ADD_FAILED' });+ }+ pushLog(`[RUNTIME] 加開平台: ${name}`);+ if (name === 'tiktok' && tiktokChatSource() === 'browser') {+ const handle = process.env.TIKTOK_NAME || '';+ if (handle) {+ const { openTikTokLive } = require('./BrowserRoutes.cjs');+ void openTikTokLive(handle).catch(error => pushLog('[BROWSER] 加開 TikTok 瀏覽器失敗:', error.message));+ }+ }+ return { ok: true, platform: name, state: runtimeState.snapshot() };+}+ const server = http.createServer((req, res) => { if (serveAudience(req,res,isValidToken(parseCookies(req).authToken))) return; if (servePlanner(req,res)) return; if (require('./TrafficRoutes.cjs').serveTraffic(req,res,isValidToken(parseCookies(req).authToken))) return; if (serveMonitorHistory(req, res, isValidToken(parseCookies(req).authToken))) return; if (require('./RestreamRoutes.cjs').serveRestream(req,res,isValidToken(parseCookies(req).authToken))) return;- if (serveRuntime(req, res, runtimeState, startRuntime)) return;+ if (serveRuntime(req, res, runtimeState, startRuntime, addRuntimePlatform)) return; if (diagnosticsRoutes.serveIntake(req, res)) return; if (diagnosticsRoutes.servePage(req, res)) return; if (diagnosticsRoutes.serveApi(req, res, isValidToken(parseCookies(req).authToken))) return;diff --git a/Test/auto_stop.test.mjs b/Test/auto_stop.test.mjs--- a/Test/auto_stop.test.mjs+++ b/Test/auto_stop.test.mjs@@ -98,6 +98,38 @@ test('停用、單一平台與有未知結束訊號的平台', () => { assert.ok(logs.some(message => message.includes('Kick'))); }); +test('執行中加開平台:可加入結束判斷;blocked 平台會取消倒數', () => {+ const timers = clock();+ let stopped = 0;+ const auto = createAutoStop({+ enabled: true, platforms: ['TikTok'], graceMs: 1000,+ onStop: () => { stopped++; },+ setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout+ });+ assert.equal(auto.addPlatform('Twitch'), true);+ assert.equal(auto.addPlatform('Twitch'), false);+ auto.markLive('TikTok');+ auto.markLive('Twitch');+ auto.markEnded('TikTok');+ auto.markEnded('Twitch');+ assert.equal(timers.pending(), 1);+ timers.run(timers.ids()[0]);+ assert.equal(stopped, 1);++ const blockedTimers = clock();+ const blocked = createAutoStop({+ enabled: true, platforms: ['TikTok'], graceMs: 1000,+ onStop: () => { throw new Error('should not stop'); },+ setTimeout: blockedTimers.setTimeout, clearTimeout: blockedTimers.clearTimeout+ });+ blocked.markLive('TikTok');+ blocked.markEnded('TikTok');+ assert.equal(blockedTimers.pending(), 1);+ blocked.addPlatform('Kick', { blocked: true });+ assert.equal(blockedTimers.pending(), 0);+ assert.equal(blocked.state().Kick, 'live');+});+ test('啟動時還沒觀測到直播,不會因為離線訊號而停止', () => { const timers = clock(); const auto = createAutoStop({diff --git a/Test/monitor_audience_ui.cjs b/Test/monitor_audience_ui.cjs--- a/Test/monitor_audience_ui.cjs+++ b/Test/monitor_audience_ui.cjs@@ -32,7 +32,8 @@ async function main() { ok: true, sessions: [{ id: 'history-1', startedAt: Date.now() - 3600000, endedAt: Date.now() - 1800000,- messageCount: 2, joins: 2, likes: 7, chats: 3, platforms: ['TikTok']+ messageCount: 2, joins: 2, likes: 7, chats: 3, follows: 1,+ subscriptions: 2, gifts: 4, peakViewers: 42, platforms: ['TikTok'] }] })); return;@@ -41,7 +42,10 @@ async function main() { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok: true, total: 2, page: 1, pages: 1, pageSize: 200,- session: { id: 'history-1', startedAt: Date.now() - 3600000, endedAt: Date.now() - 1800000 },+ session: {+ id: 'history-1', startedAt: Date.now() - 3600000, endedAt: Date.now() - 1800000,+ joins: 2, likes: 7, chats: 3, follows: 1, subscriptions: 2, gifts: 4, peakViewers: 42+ }, messages: [ { id: 2, t: Date.now() - 1800000, category: 'chat', platform: 'TikTok', user: 'History Two', message: 'older' }, { id: 1, t: Date.now() - 1900000, category: 'like', platform: 'TikTok', user: 'History One', message: 'newer' }@@ -105,7 +109,7 @@ async function main() { level: 3, score: 70, labels: ['alwaysHere', 'giftRegular'], sessions: 12, streak: 6, messages: 40, gifts: 4, tenureDays: 60 },- session: { joins: 2, likes: 7, chats: 3 }+ session: { joins: 2, likes: 7, chats: 3, follows: 1, subscriptions: 2, gifts: 4, peakViewers: 42 } }) + '\n\n'); return; }@@ -140,11 +144,18 @@ async function main() { assert.equal(await page.$eval('.mon-tags', node => node.title.includes('12')), true); const session = await page.evaluate(() => ({ hidden: document.querySelector('#monSession').hidden,+ viewers: document.querySelector('#monSessionViewers').textContent, joins: document.querySelector('#monSessionJoins').textContent, likes: document.querySelector('#monSessionLikes').textContent,+ follows: document.querySelector('#monSessionFollows').textContent,+ subscriptions: document.querySelector('#monSessionSubscriptions').textContent,+ gifts: document.querySelector('#monSessionGifts').textContent, chats: document.querySelector('#monSessionChats').textContent }));- assert.deepEqual(session, { hidden: false, joins: '2', likes: '7', chats: '3' });+ assert.deepEqual(session, {+ hidden: false, viewers: '42', joins: '2', likes: '7',+ follows: '1', subscriptions: '2', gifts: '4', chats: '3'+ }); const collapsedLayout = await page.evaluate(() => { const box = id => document.querySelector(id).getBoundingClientRect().toJSON(); return { media: box('#monMediaPanel'), raw: box('#monRawPanel'), scroll: box('#monScroll') };@@ -303,13 +314,19 @@ async function main() { page: document.querySelector('#monHistoryPage').textContent, total: document.querySelector('#monHistoryTotal').textContent, status: document.querySelector('#monStatus').textContent,- clearDisabled: document.querySelector('#monClear').disabled+ clearDisabled: document.querySelector('#monClear').disabled,+ viewers: document.querySelector('#monSessionViewers').textContent,+ follows: document.querySelector('#monSessionFollows').textContent,+ subscriptions: document.querySelector('#monSessionSubscriptions').textContent,+ gifts: document.querySelector('#monSessionGifts').textContent })); assert.equal(history.options, 1); assert.match(history.page, /1 \/ 1/); assert.match(history.total, /2/); assert.match(history.status, /歷史模式/); assert.equal(history.clearDisabled, true);+ assert.deepEqual([history.viewers, history.follows, history.subscriptions, history.gifts],+ ['42', '1', '2', '4']); await page.click('#monModeLive'); await page.waitForFunction(() => document.querySelector('#monHistoryBar').hidden); assert.equal(posts.length, 2);diff --git a/Test/monitor_history.test.cjs b/Test/monitor_history.test.cjs--- a/Test/monitor_history.test.cjs+++ b/Test/monitor_history.test.cjs@@ -84,6 +84,28 @@ test('每場只保留最新訊息且同步 message_count', t => { assert.equal(store.getSession(session).messageCount, 2); }); +test('場次保存跨平台本場統計與峰值人數', t => {+ const store = new MonitorHistoryStore({ file: ':memory:', now: () => 1000 });+ t.after(() => store.close());+ const session = store.startSession({ startedAt: 1000 });+ store.append(session, entry(1001, {+ userNum: 10,+ session: { joins: 2, likes: 7, chats: 3, follows: 1, subscriptions: 2, gifts: 4, peakViewers: 30 }+ }));+ store.append(session, entry(1002, {+ userNum: 55,+ session: { joins: 4, likes: 7, chats: 5, follows: 2, subscriptions: 3, gifts: 4, peakViewers: 20 }+ }));+ const saved = store.listSessions()[0];+ assert.equal(saved.joins, 4);+ assert.equal(saved.likes, 7);+ assert.equal(saved.chats, 5);+ assert.equal(saved.follows, 2);+ assert.equal(saved.subscriptions, 3);+ assert.equal(saved.gifts, 4);+ assert.equal(saved.peakViewers, 55);+});+ test('歷史把 data URI materialize 成 /media,不把 base64 寫進 SQLite', t => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-monitor-media-')); let now = 1000;diff --git a/Test/platform_manager.test.mjs b/Test/platform_manager.test.mjs--- /dev/null+++ b/Test/platform_manager.test.mjs@@ -0,0 +1,45 @@+import test from 'node:test';+import assert from 'node:assert/strict';+import { createPlatformManager } from '../ScriptLib/runtime/platform-manager.mjs';++test('同一平台只啟動一次,並回報 starting/running', async () => {+ const reports = [];+ let calls = 0;+ const manager = createPlatformManager({+ starts: { tiktok: () => { calls++; return 'connecting'; } },+ report: (platform, state, error) => reports.push([platform, state, error])+ });+ const first = await manager.add('TikTok');+ assert.deepEqual(first, { ok: true, state: 'connecting' });+ const second = await manager.add('tiktok');+ assert.deepEqual(second, { ok: false, state: 'running', reason: 'already-running' });+ assert.equal(calls, 1);+ assert.deepEqual(reports, [+ ['tiktok', 'starting', undefined],+ ['tiktok', 'connecting', undefined],+ ['tiktok', 'running', undefined]+ ]);+});++test('失敗會清除啟動狀態並可重試,未知平台回報 failed', async () => {+ const reports = [];+ let attempts = 0;+ const manager = createPlatformManager({+ starts: {+ twitch: () => {+ attempts++;+ if (attempts === 1) throw new Error('token invalid');+ return 'running';+ }+ },+ report: (platform, state, error) => reports.push([platform, state, error])+ });+ await assert.rejects(() => manager.add('twitch'), /token invalid/);+ assert.equal(manager.has('twitch'), false);+ assert.ok(reports.some(([, state, error]) => state === 'failed' && error === 'token invalid'));+ assert.equal((await manager.add('twitch')).ok, true);+ const unsupported = await manager.add('unknown');+ assert.equal(unsupported.ok, false);+ assert.equal(unsupported.reason, 'unsupported');+ assert.equal(manager.has('unknown'), false);+});diff --git a/Test/runtime_state.test.cjs b/Test/runtime_state.test.cjs--- a/Test/runtime_state.test.cjs+++ b/Test/runtime_state.test.cjs@@ -34,6 +34,24 @@ test('啟動、運行與優雅停止依實際程序事件更新', () => { assert.equal(state.snapshot().pid, null); }); +test('執行中加開平台會更新平台清單與個別狀態', () => {+ const state = new RuntimeState();+ const proc = child();+ state.attach(proc, ['twitch']);+ proc.emit('spawn');+ assert.deepEqual(state.snapshot().platforms, ['twitch']);+ state.addPlatform('tiktok');+ assert.deepEqual(state.snapshot().platforms, ['twitch', 'tiktok']);+ assert.equal(state.snapshot().platformStates.tiktok, 'starting');+ state.setPlatformState('tiktok', 'running');+ assert.equal(state.snapshot().platformStates.tiktok, 'running');+ state.setPlatformState('tiktok', 'failed', 'no credentials');+ assert.equal(state.snapshot().platformErrors.tiktok, 'no credentials');+ state.removePlatform('tiktok');+ assert.deepEqual(state.snapshot().platforms, ['twitch']);+ assert.equal(state.snapshot().platformStates.tiktok, undefined);+});+ test('啟動失敗、异常退出及舊程序遲到事件不混淆', () => { const state = new RuntimeState(); const first = child();@@ -119,6 +137,32 @@ test('JSON 啟動入口驗證方法、來源與重複啟動,回報即時狀態 }); +test('執行中加開平台 API 驗證來源、方法與平台', async () => {+ const { Readable } = require('node:stream');+ const state = new RuntimeState();+ const added = [];+ const addPlatform = platform => {+ if (platform === 'twitch') throw Object.assign(new Error('already'), { status: 409, code: 'PLATFORM_ALREADY_RUNNING' });+ added.push(platform);+ state.addPlatform(platform);+ return { ok: true, platform, state: state.snapshot() };+ };+ function request(method, origin, body) {+ return new Promise(resolve => {+ const req = Readable.from([JSON.stringify(body)]);+ Object.assign(req, { url: '/api/runtime/platforms', method, headers: { host: 'localhost:3332', origin, 'content-type': 'application/json' } });+ serveRuntime(req, { writeHead(status) { this.status = status; }, end(data) { resolve({ status: this.status, data: data && JSON.parse(data) }); } }, state, null, addPlatform);+ });+ }+ assert.equal((await request('GET', 'http://localhost:3332', {})).status, 405);+ assert.equal((await request('POST', 'https://other.test', { platform: 'tiktok' })).status, 403);+ const result = await request('POST', 'http://localhost:3332', { platform: 'tiktok' });+ assert.equal(result.status, 202);+ assert.deepEqual(added, ['tiktok']);+ assert.equal(result.data.platform, 'tiktok');+ assert.equal((await request('POST', 'http://localhost:3332', { platform: 'twitch' })).status, 409);+});+ test('子程序統計以摘要記錄,完整快照仍更新快取且分段輸出不遺失', () => { const fs = require('node:fs'); const vm = require('node:vm');diff --git a/Test/runtime_ui.cjs b/Test/runtime_ui.cjs--- a/Test/runtime_ui.cjs+++ b/Test/runtime_ui.cjs@@ -55,6 +55,18 @@ function previewServer() { res.end(JSON.stringify({ options })); return; }+ if (req.method === 'POST' && url.pathname === '/api/runtime/platforms') {+ let body = '';+ req.on('data', chunk => { body += chunk; });+ req.on('end', () => {+ const { platform } = JSON.parse(body || '{}');+ if (!state.platforms.includes(platform)) state.platforms.push(platform);+ state.platformStates = { ...(state.platformStates || {}), [platform]: 'starting' };+ res.writeHead(202, { 'Content-Type': 'application/json; charset=utf-8' });+ res.end(JSON.stringify({ ok: true, platform, state }));+ });+ return;+ } if (req.method === 'POST' && url.pathname === '/close') { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ success: true }));@@ -104,6 +116,16 @@ async function main() { assert.match(await page.$eval('#mode-youtube', el => el.textContent), /手動 userscript/); assert.equal(await page.$eval('#mode-youtube-badge', el => el.dataset.mode), 'manual'); assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);+ assert.equal(await page.$eval('#runtime-add-card', el => el.hidden), false);+ const addButtons = await page.$$eval('#runtime-add-platforms button', nodes => nodes.map(node => node.textContent));+ assert.ok(addButtons.some(label => label.includes('Kick')));+ const [addResponse] = await Promise.all([+ page.waitForResponse(response => response.url().endsWith('/api/runtime/platforms')),+ page.click('#runtime-add-platforms button')+ ]);+ assert.equal(addResponse.status(), 202);+ await page.waitForFunction(() => Array.from(document.querySelectorAll('#runtime-add-platforms button'))+ .every(node => !node.textContent.includes('Kick'))); fs.mkdirSync(path.join(root, 'output/playwright'), { recursive: true }); await page.screenshot({ path: path.join(root, 'output/playwright/runtime-desktop.png'), fullPage: true });diff --git a/Test/twitch_events.test.mjs b/Test/twitch_events.test.mjs--- a/Test/twitch_events.test.mjs+++ b/Test/twitch_events.test.mjs@@ -1,12 +1,13 @@ import { test } from 'node:test'; import assert from 'node:assert/strict';-import { onViewer, onError, onStreamOnline, onStreamOffline, onFollow, onCheer } from '../ScriptLib/twitch/events.mjs';+import { onViewer, onError, onStreamOnline, onStreamOffline, onFollow, onCheer, onSubscription } from '../ScriptLib/twitch/events.mjs'; const silent = { log() {}, error() {} }; /** 建立一組記錄呼叫的假 deps。 */ function makeDeps(overrides = {}) { const calls = [];+ const sessionStats = { follows: 0, subscriptions: 0, gifts: 0 }; const autoClip = { resets: 0, viewers: [], reset() { this.resets++; }, updateViewers(v) { this.viewers.push(v); } }; const deps = { getAutoClip: () => autoClip,@@ -14,7 +15,8 @@ function makeDeps(overrides = {}) { writeLog: (...a) => calls.push(['log', ...a]), notify: async (...a) => calls.push(['notify', ...a]), sendSystem: (t) => calls.push(['system', t]),- sendNotice: (u, t, i) => calls.push(['notice', u, t, i]),+ sendNotice: (u, t, i, category = 'system') => calls.push(['notice', u, t, i, category]),+ sessionStats, setViewerCount: (n) => { calls.push(['viewers', n]); return n; }, resetViewerCount: () => calls.push(['resetViewer']), updateCombinedViewerCount: () => calls.push(['combined']),@@ -22,7 +24,7 @@ function makeDeps(overrides = {}) { logger: silent, ...overrides };- return { deps, calls, autoClip };+ return { deps, calls, autoClip, sessionStats }; } test('onViewer:stream 為 null 時重置 autoClip 並歸零', () => {@@ -49,17 +51,29 @@ test('onViewer:使用收斂後的有效值(短暫 0 被保留)', () => { assert.ok(calls.some(c => c[0] === 'traffic' && c[1].userNum === 42)); }); -test('onFollow:取頭像、通知與系統訊息', async () => {- const { deps, calls } = makeDeps();+test('onFollow:取頭像、通知與 follow 分類,並累計本場關注', async () => {+ const { deps, calls, sessionStats } = makeDeps(); await onFollow({ userId: 'u1', userDisplayName: 'Alice' }, deps); assert.ok(calls.some(c => c[0] === 'notify' && c[1] === 'Alice'));- assert.ok(calls.some(c => c[0] === 'notice' && c[1] === 'Alice' && c[3] === 'icon:u1'));+ assert.ok(calls.some(c => c[0] === 'notice' && c[1] === 'Alice' && c[3] === 'icon:u1' && c[4] === 'follow'));+ assert.equal(sessionStats.follows, 1); }); -test('onCheer:帶上小奇點數量', async () => {- const { deps, calls } = makeDeps();+test('onCheer:帶上小奇點數量、gift 分類與本場送禮', async () => {+ const { deps, calls, sessionStats } = makeDeps(); await onCheer({ userId: 'u2', userDisplayName: 'Bob', bits: 500 }, deps); assert.ok(calls.some(c => c[0] === 'notify' && c[2] === '送出 500 小奇點'));+ assert.ok(calls.some(c => c[0] === 'notice' && c[4] === 'gift'));+ assert.equal(sessionStats.gifts, 1);+});++test('onSubscription:訂閱與贈訂都計入 subscribe 分類', async () => {+ const { deps, calls, sessionStats } = makeDeps();+ await onSubscription({ userId: 'u3', userDisplayName: 'Cara', tier: '1000', isGift: false }, deps);+ await onSubscription({ userId: 'u4', userDisplayName: 'Dan', tier: '2000', isGift: true }, deps);+ assert.ok(calls.some(c => c[0] === 'notice' && c[1] === 'Cara' && c[4] === 'subscribe' && c[2].includes('Tier 1')));+ assert.ok(calls.some(c => c[0] === 'notice' && c[1] === 'Dan' && c[4] === 'subscribe' && c[2].includes('贈訂')));+ assert.equal(sessionStats.subscriptions, 2); }); test('onError:送出系統訊息並重置 autoClip', () => {diff --git a/TikTok.js b/TikTok.js--- a/TikTok.js+++ b/TikTok.js@@ -6,7 +6,7 @@ import { startYoutubeChat, stopYoutubeChat } from './ScriptLib/youtube/chat.mjs' import { createNotifier } from './ScriptLib/notify/index.mjs'; import { normalizeSource } from './MessageSource.mjs'; import { startTwitchClient, stopTwitchClient, getUserIcon, apiClient, tuser } from './ScriptLib/twitch/client.mjs';-import { onViewer as twitchOnViewer, onError as twitchOnError, onStreamOnline as twitchOnStreamOnline, onStreamOffline as twitchOnStreamOffline, onFollow as twitchOnFollow, onCheer as twitchOnCheer } from './ScriptLib/twitch/events.mjs';+import { onViewer as twitchOnViewer, onError as twitchOnError, onStreamOnline as twitchOnStreamOnline, onStreamOffline as twitchOnStreamOffline, onFollow as twitchOnFollow, onCheer as twitchOnCheer, onSubscription as twitchOnSubscription } from './ScriptLib/twitch/events.mjs'; import { createClipService } from './ScriptLib/twitch/clip.mjs'; import { onChat as twitchOnChat } from './ScriptLib/twitch/chat.mjs'; import { promises as fs, readFileSync, existsSync, writeFileSync } from 'fs';@@ -22,6 +22,7 @@ import { buildConnectionOptions } from './ScriptLib/tiktok/signing.mjs'; import { createTikTokConnectionManager } from './ScriptLib/tiktok/connection.mjs'; import tiktokSource from './ScriptLib/tiktok/source.cjs'; import { registerTikTokEvents } from './ScriptLib/tiktok/events.mjs';+import { createPlatformManager } from './ScriptLib/runtime/platform-manager.mjs'; import { createSponsorStore } from './ScriptLib/sponsor/store.mjs'; import { createAdScheduler } from './ScriptLib/sponsor/scheduler.mjs'; import { createSentCache } from './ScriptLib/sponsor/sent-cache.mjs';@@ -152,12 +153,13 @@ const viewerCounts = createViewerCounts({ })[platform] === true, }); -const tiktokSourceMode = isTK ? tiktokSource.tiktokChatSource() : 'native';+// 來源模式必須在啟動平台之前解析;動態加開 TikTok 時 isTK 會從 false 變成 true。+const tiktokSourceMode = tiktokSource.tiktokChatSource(); const tiktokBrowserMode = tiktokSourceMode === 'browser'; const tiktokManualMode = tiktokSourceMode === 'manual'; const tiktokNativeDisabled = tiktokBrowserMode || tiktokManualMode;-if (tiktokBrowserMode) console.log('[TikTok] 使用程式專用瀏覽器,略過原生 TikTok 連線');-if (tiktokManualMode) console.log('[TikTok] manual 模式:不啟動原生/專用瀏覽器,只收外部 userscript 的 /chat');+if (isTK && tiktokBrowserMode) console.log('[TikTok] 使用程式專用瀏覽器,略過原生 TikTok 連線');+if (isTK && tiktokManualMode) console.log('[TikTok] manual 模式:不啟動原生/專用瀏覽器,只收外部 userscript 的 /chat'); console.log('收到參數:', keyword, isTK ? '(TikTok)' : '', isTwitch ? '(Twitch)' : '', isKick ? '(Kick)' : '', isOdysee ? '(Odysee)' : '', isYoutube ? '(Youtube)' : ''); @@ -270,8 +272,12 @@ const notify = (title, comment, icon, url) => notifier.notify(title, comment, ic function platformSenders(platform) { return { system: (text) => sendSocketMessage("系統", text, { isMain: false, category: 'system', platform }),- chat: (user, message, img) => sendSocketMessage(user, message, { img, category: 'chat', platform }),- notice: (user, message, img) => sendSocketMessage(user, message, { img, isMain: false, category: 'system', platform }),+ chat: (user, message, img) => {+ sessionStats.chats += 1;+ return sendSocketMessage(user, message, { img, category: 'chat', platform });+ },+ notice: (user, message, img, category = 'system') =>+ sendSocketMessage(user, message, { img, isMain: false, category, platform }), }; } const kickSenders = platformSenders('kick');@@ -524,12 +530,8 @@ process.stdin.on('data', async (chunk) => { // 🔴 Kick 啟動指令 if (msg === 'KICK_START') {- // Kick 沒有可靠的結束訊號;一旦啟用就停用自動停止,避免誤停。- autoStop?.dispose();- if (!isKick) {- isKick = true;- startKickChat(kickDeps);- }+ // Kick 沒有可靠的結束訊號;平台管理器會把它加入 AutoStop 的 blocked 集合。+ void addPlatform('kick'); return; } @@ -875,6 +877,12 @@ const connection = new TikTokLiveConnection(tiktokName, connOpts) var CacheUserList = [] // 用於去重的用戶列表 var CacheUserNum = 0 // 合併觀眾數(由 viewerCounts 計算) +// 本場累計;所有平台的 socket payload 都帶同一份 snapshot,monitor 場次統計才不會只算 TikTok。+const sessionStats = {+ joins: 0, likes: 0, chats: 0, follows: 0, subscriptions: 0, gifts: 0, peakViewers: 0+};+const sessionSnapshot = () => ({ ...sessionStats });+ const AD_IMAGE_URL_RE = /https?:\/\/[^\s<>"']+\.(?:png|jpe?g|gif|webp|avif|bmp)(?:\?[^\s<>"']*)?/gi; @@ -912,7 +920,11 @@ function sendAdOverylayMessage(user, text, iconURL, useTTS) { * @return {*} */ function sendAudienceUpdate() {- const payload = { type: 'audience', category: 'audience', userNum: CacheUserNum, userList: CacheUserList };+ sessionStats.peakViewers = Math.max(sessionStats.peakViewers, Number(CacheUserNum) || 0);+ const payload = {+ type: 'audience', category: 'audience', userNum: CacheUserNum, userList: CacheUserList,+ session: sessionSnapshot()+ }; socketHub.send(payload); } @@ -943,7 +955,12 @@ function updateCombinedViewerCount(force = false) { * @returns {boolean} 是否實際送出(false = 被去重擋下)。 */ function sendSocketMessage(user, message, { img = '', giftImg = '', isMain = true, category, platform, dedupUser, dedupMessage, dedupVariant = '', session = null, isTest = false } = {}) {- return socketHub.sendMessage(user, message, { img, giftImg, isMain, userNum: CacheUserNum, userList: CacheUserList, category, platform, dedupUser, dedupMessage, dedupVariant, session, isTest });+ sessionStats.peakViewers = Math.max(sessionStats.peakViewers, Number(CacheUserNum) || 0);+ return socketHub.sendMessage(user, message, {+ img, giftImg, isMain, userNum: CacheUserNum, userList: CacheUserList, category, platform,+ dedupUser, dedupMessage, dedupVariant,+ session: session || sessionSnapshot(), isTest+ }); } /** Raw Socket 測試:可選擇套用正式 MediaCache/emoji 轉換,直接送 socketHub。 */@@ -1015,8 +1032,15 @@ function connectSocket() { -if (isTK && !tiktokNativeDisabled) {+/** 啟動 TikTok 原生連線;browser/manual 模式由外部 user script 提供訊息。 */+async function startTikTokPlatform() { console.log("連接 TikTok 直播間:", tiktokName)+ if (tiktokNativeDisabled) {+ console.log('[TikTok] browser/manual 模式:不建立原生連線');+ autoStop.addPlatform('TikTok', { blocked: true });+ return 'external';+ }+ autoStop.addPlatform('TikTok'); // 初始化簽名服務(僅 TikTok 模式需要) setupCustomSignServer();@@ -1030,7 +1054,6 @@ if (isTK && !tiktokNativeDisabled) { console.info(`Connected to roomId ${state.roomId}`); tiktokConn.onConnected(state); // fetchAndSyncGifts(); // eulerstream 需付費,禮物名稱由收到事件時即時翻譯- }).catch(err => { const errDetail = err.exception || err; console.error('Failed to connect', errDetail.message || errDetail);@@ -1047,6 +1070,7 @@ if (isTK && !tiktokNativeDisabled) { tiktokConn.scheduleReconnect(); }); + return 'connecting'; } @@ -1069,7 +1093,7 @@ if (tiktokLiveBeforeAutoStop) autoStop.markLive('TikTok'); // 註冊所有 TikTok 事件處理器(實作在 ScriptLib/tiktok/events.mjs) registerTikTokEvents(connection, {- tiktokName, isTK,+ tiktokName, isTK: () => isTK, notify, sendSocketMessage, logRawEvent, writeLog, writeFileSync, dedup, sentCache, processFilter, reportFiltered, reportTraffic,@@ -1081,6 +1105,7 @@ registerTikTokEvents(connection, { updateCombinedViewerCount, setCacheUserList: (n) => { CacheUserList = n; }, getAutoClip: () => autoClip,+ sessionStats, isEnd: () => isEnd, getStreamEnded: () => streamEnded, setStreamEnded: () => { streamEnded = true; }, onStreamEnd: () => autoStop.markEnded('TikTok'), stopViewCache: () => tiktokConn.stopViewCache(),@@ -1115,6 +1140,7 @@ const twitchDeps = { notify, sendSystem: twitchSenders.system, sendNotice: twitchSenders.notice,+ sessionStats, setViewerCount: (count) => { viewerCounts.set('Twitch', count); return viewerCounts.get('Twitch'); }, resetViewerCount: () => { viewerCounts.reset('Twitch'); }, updateCombinedViewerCount,@@ -1144,23 +1170,29 @@ const twitchChatDeps = { logger: console, }; -await startTwitchClient({- userName: process.env.TWITCH_USER_NAME || "coffeelatte0709",- enabled: isTwitch,- writeLog,- onError: (err) => twitchOnError(err, twitchDeps),- onStreamOnline: (event) => { autoStop.markLive('Twitch'); twitchOnStreamOnline(event, twitchDeps); },- onStreamOffline: (event) => { autoStop.markEnded('Twitch'); twitchOnStreamOffline(event, twitchDeps); },- onFollow: (event) => twitchOnFollow(event, twitchDeps),- onCheer: (event) => twitchOnCheer(event, twitchDeps),- onChat: (event) => twitchOnChat(event, twitchChatDeps),- onSocketDisconnect: () => autoClip?.reset(),- onViewer: (stream) => {- if (stream) autoStop.markLive('Twitch');- else autoStop.markEnded('Twitch');- twitchOnViewer(stream, twitchDeps);- },-});+async function startTwitchPlatform() {+ isTwitch = true;+ await startTwitchClient({+ userName: process.env.TWITCH_USER_NAME || "coffeelatte0709",+ enabled: true,+ writeLog,+ onError: (err) => twitchOnError(err, twitchDeps),+ onStreamOnline: (event) => { autoStop.markLive('Twitch'); twitchOnStreamOnline(event, twitchDeps); },+ onStreamOffline: (event) => { autoStop.markEnded('Twitch'); twitchOnStreamOffline(event, twitchDeps); },+ onFollow: (event) => twitchOnFollow(event, twitchDeps),+ onCheer: (event) => twitchOnCheer(event, twitchDeps),+ onSubscription: (event) => twitchOnSubscription(event, twitchDeps),+ onChat: (event) => twitchOnChat(event, twitchChatDeps),+ onSocketDisconnect: () => autoClip?.reset(),+ onViewer: (stream) => {+ if (stream) autoStop.markLive('Twitch');+ else autoStop.markEnded('Twitch');+ twitchOnViewer(stream, twitchDeps);+ },+ });+ autoStop.addPlatform('Twitch');+ return 'running';+} @@ -1242,7 +1274,8 @@ function pushAutoClipStats() { -if (isOdysee) {+function startOdyseePlatform() {+ isOdysee = true; startOdyseeChat(odyseeChannelName, { axios, writeLog,@@ -1263,9 +1296,12 @@ if (isOdysee) { updateCombinedViewerCount() }, })+ autoStop.addPlatform('Odysee', { blocked: true });+ return 'running'; } -if (isYoutube) {+function startYoutubePlatform() {+ isYoutube = true; startYoutubeChat(youtubeChannelName, { axios, apiKey: youtubeApiKey,@@ -1291,8 +1327,59 @@ if (isYoutube) { updateCombinedViewerCount() }, })+ autoStop.addPlatform('Youtube', { blocked: true });+ return 'running'; } -if (isKick) {+function startKickPlatform() {+ isKick = true; startKickChat(kickDeps);+ autoStop.addPlatform('Kick', { blocked: true });+ return 'running';+}++// ─── 執行中加開平台;同一平台只啟動一次,IPC 回報狀態給主服務 ───+function reportPlatformState(platform, state, error = '') {+ if (typeof process.send !== 'function') return;+ try {+ process.send({ type: 'PLATFORM_STATE', data: { platform, state, error } });+ } catch { /* IPC 已關閉 */ }+}++const platformManager = createPlatformManager({+ starts: {+ tiktok: startTikTokPlatform,+ twitch: startTwitchPlatform,+ kick: startKickPlatform,+ youtube: startYoutubePlatform,+ odysee: startOdyseePlatform+ },+ report: reportPlatformState,+ onStart: name => {+ if (name === 'tiktok') isTK = true;+ if (name === 'twitch') isTwitch = true;+ if (name === 'kick') isKick = true;+ if (name === 'youtube') isYoutube = true;+ if (name === 'odysee') isOdysee = true;+ },+ log: message => console.error(message)+});+const addPlatform = platform => platformManager.add(platform);++if (typeof process.on === 'function') {+ process.on('message', message => {+ if (message?.type !== 'ADD_PLATFORM') return;+ void addPlatform(message.platform).catch(error => {+ console.error(`[PLATFORM] 加開 ${message.platform} 失敗:`, error.message || error);+ });+ });+}++for (const [platform, enabled] of [+ ['tiktok', isTK], ['twitch', isTwitch], ['kick', isKick],+ ['youtube', isYoutube], ['odysee', isOdysee]+]) {+ if (!enabled) continue;+ try { await addPlatform(platform); }+ catch (error) { console.error(`[PLATFORM] ${platform} 啟動失敗:`, error.message || error); } }diff --git a/assets/app.css.build.json b/assets/app.css.build.json--- a/assets/app.css.build.json+++ b/assets/app.css.build.json@@ -1,5 +1,5 @@ { "schema": 2,- "inputHash": "b904a07281dcfbda88874f967a15ccd683e3d022a1fe24006a5a701f3f4473f2",+ "inputHash": "edd735287091b41b0fcd39c76675eb440e992fdf9ff292d33ea1b5ebd06e9ded", "outputHash": "5e8e8ac9b7a490acf78fc06ee9e9c0e63e69a0a32e695f93025dce6a24930dce" }diff --git a/assets/monitor.js b/assets/monitor.js--- a/assets/monitor.js+++ b/assets/monitor.js@@ -43,8 +43,12 @@ const statusEl = document.getElementById('monStatus'); const jumpLatestEl = document.getElementById('monJumpLatest'); const sessionEl = document.getElementById('monSession');+ const sessionViewersEl = document.getElementById('monSessionViewers'); const sessionJoinsEl = document.getElementById('monSessionJoins'); const sessionLikesEl = document.getElementById('monSessionLikes');+ const sessionFollowsEl = document.getElementById('monSessionFollows');+ const sessionSubscriptionsEl = document.getElementById('monSessionSubscriptions');+ const sessionGiftsEl = document.getElementById('monSessionGifts'); const sessionChatsEl = document.getElementById('monSessionChats'); const modeLiveEl = document.getElementById('monModeLive'); const modeHistoryEl = document.getElementById('monModeHistory');@@ -113,7 +117,7 @@ unseen: 0, session: null, mode: 'live',- history: { sessions: [], sessionId: '', page: 1, pages: 1, total: 0, messages: [], loading: false },+ history: { sessions: [], sessionId: '', session: null, page: 1, pages: 1, total: 0, messages: [], loading: false }, media: { page: 1, pages: 1, total: 0, items: [], q: '', type: '', status: '', loading: false, loaded: false }, statusKey: 'monitor.reconnecting' };@@ -129,12 +133,16 @@ /** 顯示子程序維護的本場累計;缺少欄位視為 0。 */ function updateSession() {- const session = state.session;+ const session = state.mode === 'history' ? state.history.session : state.session; if (!session) { sessionEl.hidden = true; return; } const count = value => Math.max(0, Number(value) || 0); sessionEl.hidden = false;+ sessionViewersEl.textContent = String(count(session.peakViewers)); sessionJoinsEl.textContent = String(count(session.joins)); sessionLikesEl.textContent = String(count(session.likes));+ sessionFollowsEl.textContent = String(count(session.follows));+ sessionSubscriptionsEl.textContent = String(count(session.subscriptions));+ sessionGiftsEl.textContent = String(count(session.gifts)); sessionChatsEl.textContent = String(count(session.chats)); } @@ -174,6 +182,7 @@ shown: history.messages.length, total: history.total }, `${history.messages.length} / ${history.total}`); jumpLatestEl.hidden = true;+ updateSession(); setHistoryStatus(t('monitor.history.status', {}, '歷史模式')); } @@ -184,6 +193,8 @@ historyTotalEl.textContent = ''; historyPrevEl.disabled = true; historyNextEl.disabled = true;+ state.history.session = null;+ updateSession(); } async function loadHistoryMessages(page = 1) {@@ -206,7 +217,7 @@ if (!response.ok || !data.ok) throw new Error(data.error || 'HTTP ' + response.status); state.history = { ...history,- sessionId: data.session.id, page: data.page, pages: data.pages,+ sessionId: data.session.id, session: data.session, page: data.page, pages: data.pages, total: data.total, messages: data.messages || [], loading: false }; renderHistory();@@ -239,14 +250,18 @@ })); historySessionEl.disabled = sessions.length === 0; if (!sessions.length) {- state.history = { ...state.history, sessionId: '', page: 1, pages: 1, total: 0, messages: [] };+ state.history = {+ ...state.history, sessionId: '', session: null, page: 1, pages: 1, total: 0, messages: []+ }; clearHistoryRows();+ updateSession(); setHistoryStatus(t('monitor.history.noSessions', {}, '尚無歷史場次。')); return; } const selected = sessions.some(session => session.id === state.history.sessionId) ? state.history.sessionId : sessions[0].id; state.history.sessionId = selected;+ state.history.session = sessions.find(session => session.id === selected) || null; historySessionEl.value = selected; await loadHistoryMessages(1); } catch (error) {@@ -266,6 +281,7 @@ clearEl.disabled = !live; if (live) { renderAll();+ updateSession(); updateJump(); setStatus(state.statusKey); } else {diff --git a/assets/runtime-start.js b/assets/runtime-start.js--- a/assets/runtime-start.js+++ b/assets/runtime-start.js@@ -3,6 +3,10 @@ const launch = document.getElementById('launch'), reset = document.getElementById('reset-options'); const error = document.getElementById('launch-error'), key = 'ttw.runtime.options'; let options = [], state = null, busy = false;+ const addCard = document.getElementById('runtime-add-card');+ const addList = document.getElementById('runtime-add-platforms');+ const addStatus = document.getElementById('runtime-add-status');+ const platformKeys = { isTK: 'tiktok', isTwitch: 'twitch', isKick: 'kick', isOdysee: 'odysee', isYouTube: 'youtube' }; function toast(message, tone = 'info') { window.runtimeToast?.(message, tone); } /** 僅讀寫白名單欄位;儲存不可用時仍能在當頁啟動。 */ function values() {@@ -14,6 +18,61 @@ return result; } function save() { try { localStorage.setItem(key, JSON.stringify(values())); } catch (err) { console.warn('無法儲存啟動選項:', err?.message || err); } }+ function addPlatform(platform) {+ if (busy) return;+ busy = true;+ addStatus.textContent = '';+ fetch('/api/runtime/platforms', {+ method: 'POST',+ headers: { 'Content-Type': 'application/json' },+ body: JSON.stringify({ platform })+ }).then(async response => {+ const result = await response.json().catch(() => ({}));+ if (!response.ok) throw Error(i18n.t('error.' + result.code, {}, result.error || '加開失敗'));+ toast(i18n.t('runtime.addPlatformSent', {platform}, '已送出加開 {platform} 要求'), 'success');+ await window.runtimeRefresh?.(false);+ }).catch(failure => {+ addStatus.textContent = failure.message;+ toast(i18n.t('runtime.addPlatformFailed', {message:failure.message}, '加開失敗:{message}'), 'error');+ }).finally(() => { busy = false; renderAddPlatforms(state); });+ }++ function renderAddPlatforms(next) {+ if (!addCard || !addList) return;+ const running = next && ['running', 'starting'].includes(next.state);+ addCard.hidden = !running;+ if (!running) return;+ const active = new Set((next.platforms || []).map(value => String(value).toLowerCase()));+ const failed = next.platformErrors || {};+ const errors = Object.entries(failed);+ addStatus.textContent = errors.length+ ? errors.map(([platform, message]) => `${platform}: ${message}`).join(' · ')+ : '';+ const candidates = options.filter(option => option.group === 'platforms');+ const buttons = [];+ for (const option of candidates) {+ const platform = platformKeys[option.key];+ if (!platform || (active.has(platform) && !failed[platform])) continue;+ const retry = Boolean(failed[platform]);+ const button = document.createElement('button');+ button.type = 'button';+ button.className = 'runtime-button runtime-button-secondary';+ button.textContent = retry+ ? i18n.t('runtime.retryPlatformButton', { platform: option.label }, '重新加開 ' + option.label)+ : i18n.t('runtime.addPlatformButton', { platform: option.label }, '加開 ' + option.label);+ button.disabled = busy;+ button.addEventListener('click', () => addPlatform(platform));+ buttons.push(button);+ }+ if (!buttons.length) {+ const done = document.createElement('span');+ done.className = 'text-sm text-gray-400';+ done.textContent = i18n.t('runtime.allPlatformsActive', {}, '所有已設定的平台都已在本次執行中。');+ buttons.push(done);+ }+ addList.replaceChildren(...buttons);+ }+ function update(next) { state = next; const locked = busy || !state || !['stopped', 'failed'].includes(state.state) || !options.length;@@ -22,6 +81,7 @@ const active = state?.features ? options.filter(o => o.group === 'transport' && state.features[o.key]).map(o => o.label) : []; document.getElementById('active-features').textContent = active.length ? i18n.t('runtime.activeFeatures', {}, '本次功能:') + active.join(', ') : '';+ renderAddPlatforms(state); } /** 平台名稱與欄位由後端提供;所有文字使用 textContent。 */ async function init() {diff --git a/lang/en.json b/lang/en.json--- a/lang/en.json+++ b/lang/en.json@@ -333,6 +333,10 @@ "monitor.autoscroll": "Auto-scroll", "monitor.newMessages": "{count} new messages ↓", "monitor.session.title": "This stream",+ "monitor.session.viewers": "Peak viewers",+ "monitor.session.follows": "Follows",+ "monitor.session.subscriptions": "Subs",+ "monitor.session.gifts": "Gifts", "monitor.session.joins": "Joins", "monitor.session.likes": "Likes", "monitor.session.chats": "Chats",@@ -552,6 +556,13 @@ "error.TAB_CLOSED": "The tab was closed. Refresh the page", "error.BROWSER_CONFIG_INVALID": "Invalid browser configuration. Check BROWSER_DEBUG_URL", "runtime.options": "Startup options",+ "runtime.addPlatforms": "Add platform while running",+ "runtime.addPlatformHint": "No child-process restart; the current monitor session and stats stay intact.",+ "runtime.addPlatformButton": "Add {platform}",+ "runtime.retryPlatformButton": "Retry {platform}",+ "runtime.addPlatformSent": "Requested to add {platform}",+ "runtime.addPlatformFailed": "Add failed: {message}",+ "runtime.allPlatformsActive": "All configured platforms are already running in this session.", "runtime.optionsNote": "Select platforms or features. Leave accounts blank to use existing settings. Choices are saved in this browser.", "runtime.launch": "Start service", "runtime.reset": "Reset defaults",diff --git a/lang/zh-TW.json b/lang/zh-TW.json--- a/lang/zh-TW.json+++ b/lang/zh-TW.json@@ -333,6 +333,10 @@ "monitor.autoscroll": "自動捲動", "monitor.newMessages": "{count} 則新訊息 ↓", "monitor.session.title": "本場",+ "monitor.session.viewers": "人數峰值",+ "monitor.session.follows": "關注",+ "monitor.session.subscriptions": "訂閱",+ "monitor.session.gifts": "送禮", "monitor.session.joins": "加入", "monitor.session.likes": "點讚", "monitor.session.chats": "聊天",@@ -552,6 +556,13 @@ "error.TAB_CLOSED": "分頁已關閉,請重新整理", "error.BROWSER_CONFIG_INVALID": "瀏覽器設定錯誤,請檢查 BROWSER_DEBUG_URL", "runtime.options": "啟動選項",+ "runtime.addPlatforms": "執行中加開平台",+ "runtime.addPlatformHint": "不重啟子程序,保留目前 monitor 場次與統計。",+ "runtime.addPlatformButton": "加開 {platform}",+ "runtime.retryPlatformButton": "重新加開 {platform}",+ "runtime.addPlatformSent": "已送出加開 {platform} 要求",+ "runtime.addPlatformFailed": "加開失敗:{message}",+ "runtime.allPlatformsActive": "所有已設定的平台都已在本次執行中。", "runtime.optionsNote": "勾選平台或功能;帳號留空沿用現有設定。選擇只儲存在此瀏覽器。", "runtime.launch": "啟動服務", "runtime.reset": "恢復預設",diff --git a/monitor.html b/monitor.html--- a/monitor.html+++ b/monitor.html@@ -41,8 +41,12 @@ <span id="monCount" class="text-gray-400 text-xs"></span> <div id="monSession" class="mon-session" hidden> <span class="mon-session-title" data-i18n="monitor.session.title">本場</span>+ <span><span data-i18n="monitor.session.viewers">人數</span> <b id="monSessionViewers">0</b></span> <span><span data-i18n="monitor.session.joins">加入</span> <b id="monSessionJoins">0</b></span> <span><span data-i18n="monitor.session.likes">點讚</span> <b id="monSessionLikes">0</b></span>+ <span><span data-i18n="monitor.session.follows">關注</span> <b id="monSessionFollows">0</b></span>+ <span><span data-i18n="monitor.session.subscriptions">訂閱</span> <b id="monSessionSubscriptions">0</b></span>+ <span><span data-i18n="monitor.session.gifts">送禮</span> <b id="monSessionGifts">0</b></span> <span><span data-i18n="monitor.session.chats">聊天</span> <b id="monSessionChats">0</b></span> </div> <div class="mon-mode" role="group" aria-label="訊息來源">diff --git a/runtime.html b/runtime.html--- a/runtime.html+++ b/runtime.html@@ -91,6 +91,18 @@ </div> </section> + <section id="runtime-add-card" class="runtime-card" aria-labelledby="addPlatformTitle" hidden>+ <div class="runtime-card-head">+ <div>+ <p class="text-xs uppercase tracking-widest text-blue-300">Live</p>+ <h2 id="addPlatformTitle" class="text-xl font-bold" data-i18n="runtime.addPlatforms">執行中加開平台</h2>+ <p class="text-sm text-gray-400 mt-1" data-i18n="runtime.addPlatformHint">不重啟子程序,保留目前 monitor 場次與統計。</p>+ </div>+ </div>+ <div id="runtime-add-platforms" class="flex flex-wrap gap-2"></div>+ <p id="runtime-add-status" class="text-sm mt-3" role="status"></p>+ </section>+ <section class="runtime-card" aria-labelledby="launchTitle"> <div class="runtime-card-head"> <div>