app · 89b0329

Dev index Recent changes / versions Changelog About TODO

feat(media): history materialize、LRU 與 LAN URL

89b0329 · 2026-11-11 00:31 · app
.env.exampleDocs/MEDIA_CACHE.mdDocs/TEST_FILES_REFERENCE.mdScriptLib/media/cache.cjsScriptLib/media/inline.cjsScriptLib/monitor/store.cjsServer.jsTest/media_cache.test.cjsTest/monitor_history.test.cjstodo.md
commit 89b0329df934316d5d677924346d62c08a12c5f1Author: Nuclear0709 <agp05432@gmail.com>Date:   Wed Nov 11 00:31:15 2026 +0800     feat(media): history materialize、LRU 與 LAN URL        - monitor 歷史把 data URI materialize 成 /media,SQLite 不存 base64    - inline 與歷史引用的 HTTP 圖片 pin,LRU 不誤刪;新增容量/天數 prune    - Media base 預設使用私有 LAN IPv4,MEDIA_BASE_URL 可覆寫    - 補 media cache、monitor history 與 LAN 偵測測試 diff --git a/.env.example b/.env.exampleindex a54e08e..7225ced 100644--- a/.env.example+++ b/.env.example@@ -127,8 +127,11 @@ SOCKET_API=http://192.168.0.101:9322  # ─── 圖片快取 ──────────────────────────────────────── # TikTok/Twitch 頭像、禮物圖與贊助 overlay 圖片的本地快取 URL 前綴。-# 跨機 overlay 請改成 http://<主服務區網IP>:3332-# MEDIA_BASE_URL=http://127.0.0.1:3332+# 預設自動挑目前私有 LAN IPv4(例如 192.168.x.x);需要時才覆寫。+# MEDIA_BASE_URL=http://192.168.0.102:3332+# 非 pinned HTTP 快取容量與天數;inline/歷史引用會 pin。+# MEDIA_CACHE_MAX_MB=1024+# MEDIA_CACHE_DAYS=90  # 贊助廣告管理頁面網址(Bark 通知點擊後開啟) # SPONSOR_MANAGE_URL=http://你的內網IP:3332/sponsordiff --git a/Docs/MEDIA_CACHE.md b/Docs/MEDIA_CACHE.mdindex 90f53ff..751f771 100644--- a/Docs/MEDIA_CACHE.md+++ b/Docs/MEDIA_CACHE.md@@ -17,6 +17,8 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL:   → 第一次請求時下載並寫入本機 ``` +主服務綁定 `0.0.0.0:3332`;Media URL 預設使用目前私有 IPv4(優先 192.168.x.x),不再使用 `127.0.0.1`,讓同網域的其他機器能取得圖片。只有找不到私有 IPv4 時才退回 loopback。+ ## 檔案  - `ScriptLib/media/cache.cjs`:快取、下載、SSRF 檢查與供應。@@ -39,10 +41,11 @@ MediaCache 將圖片持久化到本機,訊息只傳本地 URL:  `img`、`giftImg`、`message` 內嵌圖片、`AdOverlay.iconURL`、贊助橫幅與通知圖片都接受 png/jpeg/webp/gif data URI。 -- MediaCache 只處理 HTTP(S);data URI 原樣直傳,不抓取、不寫 DB。+- MediaCache 一般只處理 HTTP(S);data URI 在 live 路徑原樣直傳,不抓取。只有 monitor 歷史會把 data URI materialize 成 content-addressed 本機檔。 - `ScriptLib/media/inline.cjs` 驗證格式、encoded 大小、數量與總量;不切片、不 fallback HTTP。 - 通知會移除 body 中的 data URI,且外部 Bark/ntfy 不帶 data URI icon。-- Live 顯示保留 inline;歷史持久化不應直接寫 base64,必要時 materialize 成 `/media/<content-hash>`。+- Live 顯示保留 inline;monitor 歷史會 materialize 成 `/media/<content-hash>`,SQLite 只存本地 URL,不存 base64。+- inline 與歷史引用的 HTTP 圖片會 pin,一般 LRU 不會刪掉;歷史場次/訊息被修剪後才釋放引用。 - Python overlay 已在 texture worker 本機解碼 png/jpeg/webp/gif data URI;日誌只記錄類型與長度。  ## 檢視與手動測試@@ -68,9 +71,11 @@ DNS rebinding 仍是殘餘風險;若來源不需要任意主機,之後可加  | 變數 | 預設 | 說明 | | --- | --- | --- |-| `MEDIA_BASE_URL` | `http://127.0.0.1:3332` | 回傳給 overlay 的本地 URL 前綴;跨機請改區網 IP |+| `MEDIA_BASE_URL` | 自動挑私有 LAN IPv4(例如 `http://192.168.0.102:3332`) | 回傳給 overlay 的本地 URL 前綴;有多張網卡或要反向代理時再覆寫 |+| `MEDIA_CACHE_MAX_MB` | `1024` | 非 pinned HTTP 快取容量上限 |+| `MEDIA_CACHE_DAYS` | `90` | 非 pinned HTTP 快取保留天數 | -目前沒有自動 LRU/總容量上限;Phase 2 再補保留期限、容量與清理介面。+主服務每小時 prune 一次;inline 與 monitor 歷史引用的檔案是 pinned,不會被一般 LRU 刪除。歷史修剪後會解除 HTTP pin,inline 則直接刪檔。  ## 驗證 diff --git a/Docs/TEST_FILES_REFERENCE.md b/Docs/TEST_FILES_REFERENCE.mdindex cc826a3..86a8ff6 100644--- a/Docs/TEST_FILES_REFERENCE.md+++ b/Docs/TEST_FILES_REFERENCE.md@@ -58,11 +58,11 @@ node Test/xxx.mjs | `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 的場次、分頁、搜尋、保留與測試訊息排除 |+| `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 劇院樣式還原、可拖動按鈕與拖曳不誤觸 |-| `Test/media_cache.test.cjs` | 圖片快取本地 URL、下載、SVG/私有位址拒絕、路由與 SocketHub 改寫 |+| `Test/media_cache.test.cjs` | 圖片快取、inline pin、LRU/大小上限、SVG/私有位址拒絕、路由與 SocketHub 改寫 | | `Test/inline_image.test.cjs` | data URI 圖片格式、大小/數量限制與通知剝離 | | `Test/socket_test_routes.test.cjs` | Raw Socket 測試入口的登入、子程序檢查與 payload 轉送 | | `Test/diagnostics.test.cjs` | 診斷 store、`/post`、diagnostic、token、Socket 外部事件訂閱與 API |diff --git a/ScriptLib/media/cache.cjs b/ScriptLib/media/cache.cjsindex ef30646..5275cad 100644--- a/ScriptLib/media/cache.cjs+++ b/ScriptLib/media/cache.cjs@@ -4,7 +4,9 @@ const path = require('node:path'); const crypto = require('node:crypto'); const dns = require('node:dns/promises'); const net = require('node:net');+const os = require('node:os'); const { DatabaseSync } = require('node:sqlite');+const inlineMedia = require('./inline.cjs');  const ROOT = path.resolve(__dirname, '../..'); const DEFAULT_DB = path.join(ROOT, 'data/media.sqlite');@@ -18,6 +20,26 @@ const ALLOWED_TYPES = new Set([  const hashUrl = value => crypto.createHash('sha256').update(value).digest('hex'); +/**+ * Media URL 給其他機器使用,預設挑目前區網私有 IPv4,而不是 localhost。+ * MEDIA_BASE_URL 可明確覆寫(例如反向代理或固定 hostname)。+ */+function detectMediaBaseUrl({ interfaces = os.networkInterfaces(), port = 3332, env = process.env } = {}) {+    const explicit = String(env.MEDIA_BASE_URL || '').trim();+    if (explicit) return explicit.replace(/\/+$/, '');+    const candidates = [];+    for (const addresses of Object.values(interfaces || {})) {+        for (const item of addresses || []) {+            if (![4, 'IPv4'].includes(item.family) || item.internal || !isPrivateAddress(item.address)) continue;+            candidates.push(item.address);+        }+    }+    const rank = address => address.startsWith('192.168.') ? 0+        : address.startsWith('10.') ? 1 : 2;+    candidates.sort((a, b) => rank(a) - rank(b));+    return `http://${candidates[0] || '127.0.0.1'}:${Number(port) || 3332}`;+}+ /** URL 只接受一般 HTTP(S),避免 data/blob/file 或帳密進入快取。 */ function normalizeImageUrl(value) {     const raw = String(value || '').trim();@@ -58,14 +80,18 @@ function isPrivateAddress(address) {  * 只存圖片位元組與來源 metadata,不存 HTML。  */ class MediaCache {-    constructor({ dbFile = DEFAULT_DB, imageDir = DEFAULT_DIR, baseUrl = process.env.MEDIA_BASE_URL || 'http://127.0.0.1:3332',-        fetchImpl = fetch, allowPrivateHosts = false, now = Date.now } = {}) {+    constructor({ dbFile = DEFAULT_DB, imageDir = DEFAULT_DIR, baseUrl = detectMediaBaseUrl(),+        fetchImpl = fetch, allowPrivateHosts = false, now = Date.now,+        maxBytes = (Number(process.env.MEDIA_CACHE_MAX_MB) || 1024) * 1024 * 1024,+        maxAgeMs = (Number(process.env.MEDIA_CACHE_DAYS) || 90) * 86400000 } = {}) {         this.dbFile = dbFile;         this.imageDir = imageDir;         this.baseUrl = String(baseUrl || '').replace(/\/+$/, '');         this.fetchImpl = fetchImpl;         this.allowPrivateHosts = allowPrivateHosts;         this.now = now;+        this.maxBytes = Math.max(0, Number(maxBytes) || 0);+        this.maxAgeMs = Math.max(0, Number(maxAgeMs) || 0);         this.inflight = new Map();         fs.mkdirSync(path.dirname(dbFile), { recursive: true });         fs.mkdirSync(imageDir, { recursive: true });@@ -83,10 +109,14 @@ class MediaCache {                 fetched_at INTEGER,                 last_access INTEGER,                 fail_count INTEGER NOT NULL DEFAULT 0,-                last_fail_at INTEGER+                last_fail_at INTEGER,+                source TEXT NOT NULL DEFAULT 'http',+                pinned INTEGER NOT NULL DEFAULT 0             );         `);-        this.known = new Set(this.db.prepare('SELECT hash FROM media').all().map(row => row.hash));+        const columns = new Set(this.db.prepare('PRAGMA table_info(media)').all().map(row => row.name));+        if (!columns.has('source')) this.db.exec("ALTER TABLE media ADD COLUMN source TEXT NOT NULL DEFAULT 'http'");+        if (!columns.has('pinned')) this.db.exec('ALTER TABLE media ADD COLUMN pinned INTEGER NOT NULL DEFAULT 0');     }      filePath(hash) {@@ -100,9 +130,8 @@ class MediaCache {         if (url.startsWith(this.baseUrl + '/media/')) return url;         const hash = hashUrl(url);         try {-            this.db.prepare(`INSERT INTO media(hash,url,last_access) VALUES(?,?,?)-                ON CONFLICT(hash) DO UPDATE SET url=excluded.url`).run(hash, url, this.now());-            this.known.add(hash);+            this.db.prepare(`INSERT INTO media(hash,url,last_access,source,pinned) VALUES(?,?,?,?,?)+                ON CONFLICT(hash) DO UPDATE SET url=excluded.url`).run(hash, url, this.now(), 'http', 0);             this.warm(hash, url);         } catch {             return value;@@ -110,11 +139,76 @@ class MediaCache {         return `${this.baseUrl}/media/${hash}`;     } +    /** 背景預抓 HTTP 圖片;失敗不影響原訊息。 */     warm(hash, url) {         if (this.inflight.has(hash) || fs.existsSync(this.filePath(hash))) return;         this.ensure(hash, url).catch(() => {});     } +    /**+     * 把已驗證的 data URI 解碼為 content-addressed 檔案;只供歷史持久化使用。+     * 回傳本地 URL,不把 base64 寫進 SQLite。+     */+    storeInlineDataUri(value) {+        const decoded = inlineMedia.decodeDataImage(value);+        if (!decoded.ok) return null;+        const file = this.filePath(decoded.hash);+        if (!fs.existsSync(file)) {+            fs.mkdirSync(this.imageDir, { recursive: true });+            const temporary = file + '.' + process.pid + '.tmp';+            fs.writeFileSync(temporary, decoded.body);+            fs.renameSync(temporary, file);+        }+        this.db.prepare(`INSERT INTO media(hash,url,content_type,byte_size,etag,fetched_at,last_access,source,pinned)+            VALUES(?,?,?,?,?,?,?,'inline',1)+            ON CONFLICT(hash) DO UPDATE SET content_type=excluded.content_type,byte_size=excluded.byte_size,+                source='inline',pinned=1,last_access=excluded.last_access`).run(+            decoded.hash, 'inline:' + decoded.hash, decoded.mime, decoded.body.length,+            decoded.hash, this.now(), this.now()+        );+        return `${this.baseUrl}/media/${decoded.hash}`;+    }++    /** 讓 monitor 歷史引用的 HTTP 快取不被 LRU 清掉。 */+    pin(hash) {+        if (!/^[a-f0-9]{64}$/.test(String(hash || ''))) return;+        this.db.prepare('UPDATE media SET pinned=1 WHERE hash=?').run(hash);+    }++    /** 歷史不再引用時解除 HTTP pin;inline 由 releaseInlineExcept 處理。 */+    unpinExcept(referenced = new Set()) {+        const rows = this.db.prepare("SELECT hash FROM media WHERE pinned=1 AND source='http'").all();+        for (const row of rows) {+            if (!referenced.has(row.hash)) this.db.prepare('UPDATE media SET pinned=0 WHERE hash=?').run(row.hash);+        }+    }++    /** 釋放不再被 monitor 歷史引用的 inline 檔。 */+    releaseInlineExcept(referenced = new Set()) {+        const rows = this.db.prepare("SELECT hash FROM media WHERE source='inline'").all();+        for (const row of rows) {+            if (referenced.has(row.hash)) continue;+            try { fs.unlinkSync(this.filePath(row.hash)); } catch { /* ignore */ }+            this.db.prepare('DELETE FROM media WHERE hash=?').run(row.hash);+        }+    }++    /** 清除過期或超出容量的非 pinned HTTP 快取;inline 由引用方管理。 */+    prune() {+        const rows = this.db.prepare(`SELECT hash,byte_size,last_access FROM media+            WHERE pinned=0 ORDER BY last_access ASC, hash ASC`).all();+        let total = rows.reduce((sum, row) => sum + (Number(row.byte_size) || 0), 0);+        const cutoff = this.now() - this.maxAgeMs;+        for (const row of rows) {+            const expired = this.maxAgeMs > 0 && Number(row.last_access || 0) < cutoff;+            if (!expired && total <= this.maxBytes) continue;+            try { fs.unlinkSync(this.filePath(row.hash)); } catch { /* ignore */ }+            this.db.prepare('DELETE FROM media WHERE hash=?').run(row.hash);+            total -= Number(row.byte_size) || 0;+        }+        return { remainingBytes: total };+    }+     async assertPublicUrl(url) {         if (this.allowPrivateHosts) return;         const host = new URL(url).hostname;@@ -248,6 +342,8 @@ class MediaCache {             fetchedAt: row.fetched_at || null,             lastAccess: row.last_access || null,             failCount: row.fail_count || 0,+            source: row.source || 'http',+            pinned: row.pinned === 1,             status: this.statusOf(row)         };     }@@ -290,4 +386,4 @@ class MediaCache {  const defaultMediaCache = new MediaCache(); -module.exports = { MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress };+module.exports = { MediaCache, defaultMediaCache, hashUrl, normalizeImageUrl, isPrivateAddress, detectMediaBaseUrl };diff --git a/ScriptLib/media/inline.cjs b/ScriptLib/media/inline.cjsindex b141c54..b60f7b8 100644--- a/ScriptLib/media/inline.cjs+++ b/ScriptLib/media/inline.cjs@@ -2,6 +2,7 @@  * Inline data URI 圖片驗證。這不是 URL 快取:data URI 由 App 本機解碼,  * 伺服器只檢查格式與 encoded 大小,不抓取、不切片、不改寫。  */+const crypto = require('node:crypto'); const ALLOWED_MIME = new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif']); const MAX_IMAGE_BYTES = 256 * 1024; const MAX_TOTAL_BYTES = 768 * 1024;@@ -28,6 +29,20 @@ function parseDataImage(value) {     return { ok: true, mime, encodedBytes, payload: match[2] }; } +/** 解碼已驗證的 data URI;供歷史 materialize 使用,不做外部請求。 */+function decodeDataImage(value) {+    const parsed = parseDataImage(value);+    if (!parsed.ok) return parsed;+    if (parsed.encodedBytes > MAX_IMAGE_BYTES) return { ok: false, reason: 'image-too-large' };+    const payload = parsed.payload + '='.repeat((-parsed.payload.length) % 4);+    const body = Buffer.from(payload, 'base64');+    if (!body.length) return { ok: false, reason: 'empty-image' };+    return {+        ok: true, mime: parsed.mime, body,+        hash: crypto.createHash('sha256').update(body).digest('hex')+    };+}+ /** 從文字抽出所有 data image URI(包含不支援的 MIME,讓驗證層回報)。 */ function extractDataImages(text) {     if (typeof text !== 'string') return [];@@ -73,5 +88,5 @@ function validateInlineImages({ img = '', giftImg = '', message = '' } = {}) {  module.exports = {     ALLOWED_MIME, MAX_IMAGE_BYTES, MAX_TOTAL_BYTES, MAX_COUNT,-    isInlineImage, parseDataImage, extractDataImages, stripInlineImages, validateInlineImages+    isInlineImage, parseDataImage, decodeDataImage, extractDataImages, stripInlineImages, validateInlineImages };diff --git a/ScriptLib/monitor/store.cjs b/ScriptLib/monitor/store.cjsindex 7604248..cf66e38 100644--- a/ScriptLib/monitor/store.cjs+++ b/ScriptLib/monitor/store.cjs@@ -2,8 +2,10 @@ const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const { DatabaseSync } = require('node:sqlite');+const inlineMedia = require('../media/inline.cjs');  const DAY = 86400000;+const INLINE_IMAGE_RE = /data:image\/[a-z0-9.+-]+;base64,[A-Za-z0-9+/]+={0,2}/gi;  /**  * 直播訊息歷史:每個子程序啟動是一場,訊息以 SQLite 保存,供重啟後回顧。@@ -17,14 +19,16 @@ class MonitorHistoryStore {      * @param {number} [options.maxSessions=10]      * @param {number} [options.maxMessagesPerSession=20000]      * @param {() => number} [options.now]+     * @param {Object} [options.mediaCache] MediaCache;提供時把 data URI materialize 成 /media。      */     constructor({ file = path.join(__dirname, '../../data/monitor.sqlite'), retentionDays = 14,-        maxSessions = 10, maxMessagesPerSession = 20000, now = Date.now } = {}) {+        maxSessions = 10, maxMessagesPerSession = 20000, now = Date.now, mediaCache = null } = {}) {         this.file = file;         this.retentionDays = Math.max(1, retentionDays);         this.maxSessions = Math.max(1, maxSessions);         this.maxMessagesPerSession = Math.max(1, maxMessagesPerSession);         this.now = now;+        this.mediaCache = mediaCache;         this.appended = 0;         if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true });         this.db = new DatabaseSync(file);@@ -56,10 +60,17 @@ 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);+            CREATE TABLE IF NOT EXISTS message_media (+                message_id INTEGER NOT NULL REFERENCES messages(id) ON DELETE CASCADE,+                hash TEXT NOT NULL,+                PRIMARY KEY(message_id, hash)+            );+            CREATE INDEX IF NOT EXISTS message_media_hash ON message_media(hash);         `);         // 主服務重啟時,上一個子程序已不在;把殘留的開放場次收尾。         this.db.prepare('UPDATE sessions SET ended_at=COALESCE(last_message_at,started_at) WHERE ended_at IS NULL').run();         this.prune();+        this.syncInlineReferences();     }      startSession({ startedAt = this.now() } = {}) {@@ -74,6 +85,71 @@ class MonitorHistoryStore {         this.db.prepare('UPDATE sessions SET ended_at=? WHERE id=? AND ended_at IS NULL').run(Number(endedAt), id);     } +    collectLocalHashes(value, hashes) {+        if (typeof value !== 'string') return;+        for (const match of value.matchAll(/\/media\/([a-f0-9]{64})/g)) hashes.add(match[1]);+    }++    storeInline(value, hashes) {+        if (!this.mediaCache || typeof value !== 'string' || !inlineMedia.isInlineImage(value)) return value;+        try {+            const local = this.mediaCache.storeInlineDataUri(value);+            const hash = /\/media\/([a-f0-9]{64})$/.exec(local || '')?.[1];+            if (hash) {+                hashes.add(hash);+                return local;+            }+        } catch { /* fall through */ }+        return '';+    }++    materializeMessage(message, hashes) {+        const dataUris = inlineMedia.extractDataImages(message);+        if (!dataUris.length) return message;+        if (!this.mediaCache) return inlineMedia.stripInlineImages(message) || '[inline image]';+        if (!inlineMedia.validateInlineImages({ message }).ok) {+            return message.replace(INLINE_IMAGE_RE, '[inline image]');+        }+        let result = message;+        for (const dataUri of dataUris) {+            const local = this.storeInline(dataUri, hashes);+            result = result.replace(dataUri, local || '[inline image]');+        }+        return result;+    }++    /** 只影響寫入歷史的副本;live socket/SSE 仍保留原始 data URI。 */+    prepareEntry(entry) {+        const hashes = new Set();+        const validation = inlineMedia.validateInlineImages({+            img: entry.img, giftImg: entry.giftImg, message: entry.message+        });+        if (!validation.ok) {+            return {+                message: String(entry.message).replace(INLINE_IMAGE_RE, '[inline image]'),+                img: inlineMedia.isInlineImage(entry.img) ? '' : entry.img,+                giftImg: inlineMedia.isInlineImage(entry.giftImg) ? '' : entry.giftImg,+                hashes+            };+        }+        return {+            message: this.materializeMessage(String(entry.message), hashes),+            img: this.storeInline(entry.img, hashes),+            giftImg: this.storeInline(entry.giftImg, hashes),+            hashes+        };+    }++    /** 清掉 monitor 已不再引用的 inline media;由場次/訊息修剪後呼叫。 */+    syncInlineReferences() {+        if (!this.mediaCache || typeof this.mediaCache.releaseInlineExcept !== 'function') return;+        const referenced = new Set(+            this.db.prepare('SELECT DISTINCT hash FROM message_media').all().map(row => row.hash)+        );+        this.mediaCache.releaseInlineExcept(referenced);+        if (typeof this.mediaCache.unpinExcept === 'function') this.mediaCache.unpinExcept(referenced);+    }+     /**      * @param {string} sessionId      * @param {Object} entry 監控 payload。@@ -87,14 +163,25 @@ class MonitorHistoryStore {         const stats = entry.session && typeof entry.session === 'object' ? entry.session : {};         const audience = entry.audience && typeof entry.audience === 'object'             ? JSON.stringify(entry.audience) : null;+        const prepared = this.prepareEntry(entry);+        // 已是 /media/<hash> 的 HTTP 圖片也要 pin,避免 LRU 刪掉歷史還在用的檔。+        for (const value of [prepared.message, prepared.img, prepared.giftImg]) {+            this.collectLocalHashes(value, prepared.hashes);+        }+        if (this.mediaCache && typeof this.mediaCache.pin === 'function') {+            for (const hash of prepared.hashes) this.mediaCache.pin(hash);+        }         this.db.exec('BEGIN IMMEDIATE');         try {             this.db.prepare(`INSERT INTO messages(session_id,t,category,platform,user,message,img,gift_img,audience)                 VALUES(?,?,?,?,?,?,?,?,?)`).run(                 sessionId, t,-                entry.category ?? null, entry.platform ?? null, entry.user ?? null, entry.message,-                entry.img ?? null, entry.giftImg ?? null, audience+                entry.category ?? null, entry.platform ?? null, entry.user ?? null, prepared.message,+                prepared.img ?? null, prepared.giftImg ?? null, audience             );+            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);             this.db.prepare(`UPDATE sessions SET                 last_message_at=?, message_count=message_count+1,                 joins=MAX(joins, ?), likes=MAX(likes, ?), chats=MAX(chats, ?)@@ -102,6 +189,7 @@ class MonitorHistoryStore {             this.db.exec('COMMIT');         } catch (error) {             try { this.db.exec('ROLLBACK'); } catch { /* ignore */ }+            this.syncInlineReferences();             throw error;         }         this.appended += 1;@@ -119,6 +207,7 @@ class MonitorHistoryStore {             SELECT id FROM messages WHERE session_id=? ORDER BY t ASC, id ASC LIMIT ?         )`).run(sessionId, removed);         this.db.prepare('UPDATE sessions SET message_count=MAX(0, message_count-?) WHERE id=?').run(removed, sessionId);+        this.syncInlineReferences();     }      /** 刪除超過天數或場次上限的歷史。 */@@ -128,6 +217,7 @@ class MonitorHistoryStore {         this.db.prepare(`DELETE FROM sessions WHERE id NOT IN (             SELECT id FROM sessions ORDER BY started_at DESC LIMIT ?         )`).run(this.maxSessions);+        this.syncInlineReferences();     }      getSession(id) {diff --git a/Server.js b/Server.jsindex 8d55a9d..88f02f7 100644--- a/Server.js+++ b/Server.js@@ -59,6 +59,7 @@ const audience = createAudienceTracker({ }); const serveAudience = createAudienceRoutes(audience); const mediaRoutes = require('./MediaRoutes.cjs');+const { defaultMediaCache } = require('./ScriptLib/media/cache.cjs'); const serveMedia = mediaRoutes.createMediaRoutes(); const serveMediaAdmin = mediaRoutes.createMediaAdminRoutes(); const { DiagnosticsStore } = require('./ScriptLib/diagnostics/store.cjs');@@ -89,9 +90,14 @@ const { createMonitorHistoryStore } = require('./ScriptLib/monitor/store.cjs'); const monitorHistory = createMonitorHistoryStore({     retentionDays: Number(process.env.MONITOR_HISTORY_DAYS) || 14,     maxSessions: Number(process.env.MONITOR_HISTORY_SESSIONS) || 10,-    maxMessagesPerSession: Number(process.env.MONITOR_HISTORY_MAX_MESSAGES) || 20000+    maxMessagesPerSession: Number(process.env.MONITOR_HISTORY_MAX_MESSAGES) || 20000,+    mediaCache: defaultMediaCache }); const serveMonitorHistory = require('./MonitorRoutes.cjs').createMonitorRoutes(monitorHistory);+const mediaPrune = setInterval(() => {+    try { defaultMediaCache.prune(); } catch (error) { console.error('[MediaCache] prune 失敗:', error.message); }+}, 3600000);+mediaPrune.unref(); let monitorSessionId = null;  let tiktokProcess = null;diff --git a/Test/media_cache.test.cjs b/Test/media_cache.test.cjsindex 90b5fce..188c97f 100644--- a/Test/media_cache.test.cjs+++ b/Test/media_cache.test.cjs@@ -5,7 +5,7 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); const http = require('node:http');-const { MediaCache, hashUrl, normalizeImageUrl } = require('../ScriptLib/media/cache.cjs');+const { MediaCache, hashUrl, normalizeImageUrl, detectMediaBaseUrl } = require('../ScriptLib/media/cache.cjs'); const { createMediaRoutes, createMediaAdminRoutes } = require('../MediaRoutes.cjs'); const { SocketHub } = require('../ScriptLib/socket/hub.mjs'); @@ -23,6 +23,17 @@ function fakeServe(cache, hash, method = 'GET', headers = {}) {     }); } +test('Media base 預設使用私有 LAN IPv4,且允許環境變數覆寫', () => {+    const interfaces = {+        lo: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],+        wifi: [{ family: 'IPv4', internal: false, address: '192.168.1.20' }],+        other: [{ family: 'IPv4', internal: false, address: '10.0.0.5' }]+    };+    assert.equal(detectMediaBaseUrl({ interfaces, env: {} }), 'http://192.168.1.20:3332');+    assert.equal(detectMediaBaseUrl({ interfaces, env: { MEDIA_BASE_URL: 'http://ttw.local/' } }), 'http://ttw.local');+    assert.equal(detectMediaBaseUrl({ interfaces: { lo: interfaces.lo }, env: {} }), 'http://127.0.0.1:3332');+});+ test('MediaCache 把外部 URL 換成本地 hash,下載後可再從快取供應', async t => {     const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-'));     const server = http.createServer((req, res) => {@@ -132,6 +143,44 @@ test('MediaRoutes 只接管 /media/<hash> 並呼叫快取', async () => {     assert.equal(route({ url: '/other', method: 'GET', headers: {} }, {}), false); }); +test('inline 圖片 pinned,LRU 不刪;釋放引用後才清檔', t => {+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-inline-'));+    const cache = new MediaCache({+        dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+        baseUrl: 'http://media.test', maxBytes: 1+    });+    t.after(() => {+        cache.close();+        fs.rmSync(dir, { recursive: true, force: true });+    });+    const local = cache.storeInlineDataUri('data:image/png;base64,iVBORw0KGgo=');+    assert.match(local, /\/media\/[a-f0-9]{64}$/);+    const hash = local.split('/').pop();+    assert.equal(fs.existsSync(cache.filePath(hash)), true);+    cache.prune();+    assert.equal(fs.existsSync(cache.filePath(hash)), true);+    cache.releaseInlineExcept(new Set());+    assert.equal(fs.existsSync(cache.filePath(hash)), false);+});++test('非 pinned HTTP 快取會依容量上限 prune', t => {+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-media-lru-'));+    const cache = new MediaCache({+        dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+        baseUrl: 'http://media.test', maxBytes: 1+    });+    t.after(() => {+        cache.close();+        fs.rmSync(dir, { recursive: true, force: true });+    });+    const hash = 'b'.repeat(64);+    fs.writeFileSync(cache.filePath(hash), PNG);+    cache.db.prepare(`INSERT INTO media(hash,url,content_type,byte_size,last_access,source,pinned)+        VALUES(?,?,?,?,?,'http',0)`).run(hash, 'http://example/x.png', 'image/png', PNG.length, Date.now());+    cache.prune();+    assert.equal(fs.existsSync(cache.filePath(hash)), false);+});+ test('SocketHub 送出前改寫 img 與 giftImg', () => {     const sent = [];     const hub = new SocketHub({diff --git a/Test/monitor_history.test.cjs b/Test/monitor_history.test.cjsindex 8adfb27..bbef69d 100644--- a/Test/monitor_history.test.cjs+++ b/Test/monitor_history.test.cjs@@ -1,7 +1,11 @@ /** 常客頁以外的監控歷史 SQLite:場次、分頁、篩選、保留與測試訊息排除。 */ const test = require('node:test'); const assert = require('node:assert/strict');+const fs = require('node:fs');+const os = require('node:os');+const path = require('node:path'); const { MonitorHistoryStore } = require('../ScriptLib/monitor/store.cjs');+const { MediaCache } = require('../ScriptLib/media/cache.cjs');  function entry(t, overrides = {}) {     return {@@ -79,3 +83,33 @@ test('每場只保留最新訊息且同步 message_count', t => {     assert.deepEqual(result.messages.map(item => item.message), ['hello 3', 'hello 2']);     assert.equal(store.getSession(session).messageCount, 2); });++test('歷史把 data URI materialize 成 /media,不把 base64 寫進 SQLite', t => {+    const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ttw-monitor-media-'));+    let now = 1000;+    const cache = new MediaCache({+        dbFile: path.join(dir, 'media.sqlite'), imageDir: path.join(dir, 'images'),+        baseUrl: 'http://media.test', now: () => now+    });+    const store = new MonitorHistoryStore({+        file: path.join(dir, 'monitor.sqlite'), retentionDays: 1, now: () => now, mediaCache: cache+    });+    t.after(() => {+        store.close();+        cache.close();+        fs.rmSync(dir, { recursive: true, force: true });+    });+    const png = 'data:image/png;base64,iVBORw0KGgo=';+    const session = store.startSession({ startedAt: now });+    store.append(session, entry(1001, { message: `look ${png}`, img: png, giftImg: png }));+    const page = store.listMessages({ sessionId: session, page: 1, pageSize: 10 });+    assert.equal(page.messages[0].message.includes('data:image'), false);+    assert.match(page.messages[0].message, /http:\/\/media\.test\/media\/[a-f0-9]{64}/);+    assert.match(page.messages[0].img, /http:\/\/media\.test\/media\//);+    assert.equal(cache.list({ type: 'image/png' }).items[0].source, 'inline');+    assert.equal(fs.readdirSync(path.join(dir, 'images')).length, 1);++    now += 2 * 86400000;+    store.prune();+    assert.equal(fs.readdirSync(path.join(dir, 'images')).length, 0);+});diff --git a/todo.md b/todo.mdindex 481c67d..350adb5 100644--- a/todo.md+++ b/todo.md@@ -28,6 +28,7 @@ - [x] 應用診斷 `/diagnostics`、LogServer 相容 `/post` 與可選 token。 - [x] 診斷頁 Socket 外部事件訂閱開關(subscribeExternalLogs、自動重連)。 - [x] 未啟用 YouTube 時不輸出假斷線;handleExit 冪等。+- [x] monitor 歷史 data URI materialize 與 MediaCache LRU/容量上限。 - [ ] 正式環境以實際 child 程序驗證 IPC 結果與倒數(目前 monitor UI 使用假 SSE/fake `/chat`)。  ## 表情映射後續(2026-11-06)