app · e30add1

Dev index Recent changes / versions Changelog About TODO

feat(runtime): 跨平台本場統計與執行中加開平台

e30add1 · 2026-11-13 14:13 · app
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.mdindex 493d6f1..b88aa1c 100644--- 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.mdindex db38bf7..1a06620 100644--- 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.mdindex 3de014b..05222c9 100644--- 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.mdindex bd3765a..57f1ded 100644--- 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.mdindex 48ee050..6009680 100644--- 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.cjsindex 7f65ac3..ceeda15 100644--- 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.mjsindex ebbf9e1..ec6d3cf 100644--- 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.cjsindex 8ec698e..2a90042 100644--- 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.mjsnew file mode 100644index 0000000..ce78ee7--- /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.cjsindex 907870b..91cb635 100644--- 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.mjsindex 860cf13..636458c 100644--- 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.mjsindex 47a45de..a6f57c2 100644--- 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.mjsindex d93b870..d330287 100644--- 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.jsindex b2d6d89..677ae91 100644--- 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.mjsindex 717f980..d1ce356 100644--- 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.cjsindex 62cee3d..6fbb193 100644--- 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.cjsindex bbef69d..3300133 100644--- 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.mjsnew file mode 100644index 0000000..545e8e7--- /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.cjsindex a3efb3a..2153fa3 100644--- 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.cjsindex bf4ad9d..a69efb1 100644--- 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.mjsindex de70c31..8996948 100644--- 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.jsindex 3ecd7d3..b9c75d9 100644--- 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.jsonindex 071e064..4aa8f74 100644--- 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.jsindex 6a25580..7bd340c 100644--- 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.jsindex b631b04..e6b078c 100644--- 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.jsonindex afc3e96..bce89b3 100644--- 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.jsonindex b8cd23f..a504029 100644--- 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.htmlindex 2d6c43b..58f3835 100644--- 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.htmlindex 347b51a..30bdc42 100644--- 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>