diff --git a/danmu_api/apis/env-api.js b/danmu_api/apis/env-api.js index 95997703a..74f54ee7a 100644 --- a/danmu_api/apis/env-api.js +++ b/danmu_api/apis/env-api.js @@ -2,6 +2,7 @@ import { jsonResponse } from '../utils/http-util.js'; import { log } from '../utils/log-util.js'; import { HandlerFactory } from '../configs/handlers/handler-factory.js'; import { globals } from '../configs/globals.js'; +import { syncBangumiDataLifecycleOnConfigChange } from '../utils/bangumi-data-util.js'; import AIClient from '../utils/ai-util.js'; /** @@ -24,6 +25,10 @@ export async function handleSetEnv(request) { // 调用handler的setEnv方法 const result = await handler.setEnv(key, value); + if (result && key === 'USE_BANGUMI_DATA') { + syncBangumiDataLifecycleOnConfigChange(deployPlatform); + } + if (result) { return jsonResponse({ success: true, message: `环境变量 ${key} 设置成功` }); } else { @@ -55,6 +60,10 @@ export async function handleAddEnv(request) { // 调用handler的addEnv方法 const result = await handler.addEnv(key, value); + if (result && key === 'USE_BANGUMI_DATA') { + syncBangumiDataLifecycleOnConfigChange(deployPlatform); + } + if (result) { return jsonResponse({ success: true, message: `环境变量 ${key} 添加成功` }); } else { @@ -86,6 +95,10 @@ export async function handleDelEnv(request) { // 调用handler的delEnv方法 const result = await handler.delEnv(key); + if (result && key === 'USE_BANGUMI_DATA') { + syncBangumiDataLifecycleOnConfigChange(deployPlatform); + } + if (result) { return jsonResponse({ success: true, message: `环境变量 ${key} 删除成功` }); } else { diff --git a/danmu_api/server.js b/danmu_api/server.js index a5c1e6321..00a8cc36d 100644 --- a/danmu_api/server.js +++ b/danmu_api/server.js @@ -9,9 +9,9 @@ import { HttpsProxyAgent } from 'https-proxy-agent'; import dotenv from 'dotenv'; import { Request as NodeFetchRequest } from 'node-fetch'; import { handleRequest } from './worker.js'; -import { Globals } from './configs/globals.js'; +import { Globals, globals } from './configs/globals.js'; import { Envs } from './configs/envs.js'; -import { clearBangumiDataCache, initBangumiData } from './utils/bangumi-data-util.js'; +import { clearBangumiDataCache, initBangumiData, syncBangumiDataLifecycleOnConfigChange } from './utils/bangumi-data-util.js'; import { getLocalCaches, judgeLocalCacheValid } from './utils/cache-util.js'; import { getRedisCaches, judgeRedisValid } from './utils/redis-util.js'; import { persistFavorites, refreshFavoriteByKeyword } from './apis/favorite-api.js'; @@ -234,10 +234,11 @@ async function setupEnvWatcher() { console.log('[server] Environment variables reloaded successfully'); console.log('[server] Updated keys:', Array.from(newEnvKeys).join(', ')); - // 如果检测到关闭了 Bangumi Data 功能,主动释放内存与物理磁盘缓存 - if (process.env.USE_BANGUMI_DATA === 'false' || process.env.USE_BANGUMI_DATA === false) { - clearBangumiDataCache(true); - } + // 配置变更后同步 Bangumi Data 生命周期:开启则立即确保缓存就绪(缺失则下载), + // 关闭则释放缓存;先按真实配置同步内存开关,避免重载未刷新 globals 时状态滞后 + const bangumiEnabled = process.env.USE_BANGUMI_DATA === 'true' || process.env.USE_BANGUMI_DATA === true; + globals.useBangumiData = bangumiEnabled; + syncBangumiDataLifecycleOnConfigChange('node'); } catch (error) { console.log('[server] Error reloading configuration files:', error.message); diff --git a/danmu_api/utils/bangumi-data-util.js b/danmu_api/utils/bangumi-data-util.js index d10ad7a99..f9f2f938c 100644 --- a/danmu_api/utils/bangumi-data-util.js +++ b/danmu_api/utils/bangumi-data-util.js @@ -18,13 +18,11 @@ let downloadLockTime = 0; let memoryFootprintMB = '0.00'; let hasLoggedCacheWarning = false; let charInvertedIndex = new Map(); +let currentBackgroundDownload = null; // 在途后台下载 Promise:由 initBangumiData 在发起非阻塞刷新时写入,供边缘层 ctx.waitUntil 延长 Serverless 生命周期 -// 当前生效的数据源标识 ('custom' | 'official') -let activeDataSource = 'custom'; -// 缓存已解析的版本号(进程内有效) -let cachedCustomVersion = null; +let activeDataSource = 'custom'; // 当前生效的数据源标识 ('custom' | 'official') +let cachedCustomVersion = null; // 缓存已解析的版本号(进程内有效) let cachedOfficialVersion = null; - let versionQueryPromise = null; // 版本查询并发锁:serverless环境下冻结时异步信号可能不生效,共享同一Promise避免重复探测 // 定义缓存目录/文件名 @@ -193,6 +191,46 @@ async function selectBestDataSource() { return versionQueryPromise; } +// 数据下载触发时机:仅在 searchBangumiData 消费路径进入时触发下载;开关关闭或无所消费源时不下载 +// 开启时复用 initBangumiData 加载/下载,并发调用等待同一就绪缓存而不重复请求 +export async function ensureBangumiDataReady(deployPlatform = globals.deployPlatform) { + if (!globals.useBangumiData) return; + await initBangumiData(deployPlatform, true); +} + +// 配置变更后同步 Bangumi Data 生命周期:开关开启立即下载(已缓存则幂等),关闭释放缓存 +// 调用前须保证 globals.useBangumiData 已反映最新配置(watcher 场景须先同步内存开关) +export function syncBangumiDataLifecycleOnConfigChange(deployPlatform = globals.deployPlatform) { + if (globals.useBangumiData) { + initBangumiData(deployPlatform, true).catch(console.error); + } else { + clearBangumiDataCache(true); + } +} + +// 读取在途后台下载 Promise,供边缘运行时在请求响应后延长生命周期(无在途时返回 null) +export function getBackgroundDownload() { + return currentBackgroundDownload; +} + +// 边缘运行时在响应返回后延长生命周期:仅在存在在途后台下载且运行时提供 waitUntil 时注册 +export function extendBangumiDownloadLifecycle(ctx) { + if (ctx && typeof ctx.waitUntil === 'function' && currentBackgroundDownload) { + ctx.waitUntil(currentBackgroundDownload); + } +} + +// 发起后台静默下载并记录在途 Promise:下载完成时复位状态,供 getBackgroundDownload 暴露给边缘层 +function startDownload(cachePath) { + isDownloading = true; + downloadLockTime = Date.now(); + currentBackgroundDownload = downloadAndCache(cachePath).finally(() => { + isDownloading = false; + currentBackgroundDownload = null; + }); + return currentBackgroundDownload; +} + /** * 初始化 Bangumi Data 数据源 * 包含内存与磁盘双端缓存的生命周期校验,并在环境允许时持久化缓存数据 @@ -201,7 +239,7 @@ async function selectBestDataSource() { * @param {boolean} isDataDependentRequest - 当前是否为强依赖数据的核心接口请求 * @returns {Promise} */ -export async function initBangumiData(deployPlatform, isDataDependentRequest = false, ctx = null) { +export async function initBangumiData(deployPlatform, isDataDependentRequest = false) { if (!globals.useBangumiData) return; let cachePath = null; @@ -237,9 +275,7 @@ export async function initBangumiData(deployPlatform, isDataDependentRequest = f // 当内存过期或配置强制更新时,仅在核心请求触发后台静默更新 if (isDataDependentRequest && !isDownloading) { log("info", `[system] [Bangumi-Data] 内存数据${cacheDays === 0 ? '强制更新' : '已过期'},保留老数据服务本次请求,启动后台静默更新...`); - isDownloading = true; - downloadLockTime = Date.now(); - downloadAndCache(cachePath).finally(() => { isDownloading = false; }); + startDownload(cachePath); } return; } @@ -274,9 +310,7 @@ export async function initBangumiData(deployPlatform, isDataDependentRequest = f if (cacheDays === 0 || (Date.now() - stats.mtimeMs >= expireMs)) { if (isDataDependentRequest && !isDownloading) { log("info", `[system] [Bangumi-Data] 磁盘数据${cacheDays === 0 ? '强制更新' : '已过期'},保留老数据服务本次请求,启动后台静默更新...`); - isDownloading = true; - downloadLockTime = Date.now(); - downloadAndCache(cachePath).finally(() => { isDownloading = false; }); + startDownload(cachePath); } } return; @@ -289,19 +323,13 @@ export async function initBangumiData(deployPlatform, isDataDependentRequest = f // 内存与磁盘均无有效数据时的获取逻辑 if (!isDownloading) { log("info", `[system] [Bangumi-Data] 未命中任何有效缓存,正在获取基础数据...`); - isDownloading = true; - downloadLockTime = Date.now(); - const downloadPromise = downloadAndCache(cachePath).finally(() => { isDownloading = false; }); + const downloadPromise = startDownload(cachePath); if (isDataDependentRequest) { await downloadPromise; } else { log("info", `[system] [Bangumi-Data] 当前非核心请求,数据获取转入后台异步执行`); - if (ctx && typeof ctx.waitUntil === 'function') { - log("info", `[system] [Bangumi-Data] 调用 ctx.waitUntil 延长 Serverless 生命周期`); - ctx.waitUntil(downloadPromise); - } } } else if (isDataDependentRequest) { log("info", `[system] [Bangumi-Data] 正在等待基础数据下载完成...`); @@ -503,6 +531,7 @@ async function downloadAndCache(cachePath) { * @returns {Array} 匹配的动漫条目数组 */ export async function searchBangumiData(keyword, siteKeys) { + await ensureBangumiDataReady(); if (!memoryCache || !memoryCache.items) return []; let searchPromise = queryCache.get(keyword); diff --git a/danmu_api/utils/tmdb-util.js b/danmu_api/utils/tmdb-util.js index 159c2e414..20e0063ce 100644 --- a/danmu_api/utils/tmdb-util.js +++ b/danmu_api/utils/tmdb-util.js @@ -593,7 +593,7 @@ export async function getTMDBChineseTitle(title, season = null, episode = null) // 优先尝试本地 Bangumi Data 转换 if (globals.useBangumiData) { const cleanTitle = cleanSearchQuery(title); - const localMatches = searchBangumiData(cleanTitle, ['tmdb', 'bangumi', 'anidb']); + const localMatches = await searchBangumiData(cleanTitle, ['tmdb', 'bangumi', 'anidb']); if (localMatches && localMatches.length > 0) { const m = localMatches[0]; // 找一个不全是外文的翻译作为中文名 diff --git a/danmu_api/worker.js b/danmu_api/worker.js index 91c64d325..c10a31f7d 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -5,13 +5,13 @@ import { getFavoriteCachesFromRedis, getRedisCaches, judgeRedisValid } from "./u import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js"; import { formatDanmuResponse } from "./utils/danmu-util.js"; import AIClient from './utils/ai-util.js'; -import { initBangumiData } from "./utils/bangumi-data-util.js"; import { getBangumi, getComment, getCommentByUrl, getSegmentComment, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js"; import { handleFavoriteAdd, handleFavoriteList, handleFavoriteRefresh, handleFavoriteRemove, handleFavoriteSchedule } from "./apis/favorite-api.js"; import { getFongmiDanmaku } from "./apis/clients/fongmi-api.js"; import { handleConfig, handleUI, handleLogs, handleClearLogs, handleDeploy, handleClearCache, handleReqRecords, handleCacheAnimes } from "./apis/system-api.js"; import { handleForwardTrace } from "./apis/forward-trace-api.js"; import { handleSetEnv, handleAddEnv, handleDelEnv, handleAiVerify } from "./apis/env-api.js"; +import { extendBangumiDownloadLifecycle } from "./utils/bangumi-data-util.js"; import { Segment } from "./models/dandan-model.js" import { handleCookieStatus, @@ -23,7 +23,7 @@ import { let globals; -async function handleRequest(req, env, deployPlatform, clientIp, ctx) { +async function handleRequest(req, env, deployPlatform, clientIp) { // 加载全局变量和环境变量配置 globals = Globals.init(env); @@ -31,13 +31,6 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) { let path = url.pathname; const method = req.method; - // Bangumi Data 辅助函数,用于判断数据更新 - const isDataDependentRequest = path.includes('/search') || path.includes('/match') || path.includes('/danmaku'); - - if (globals.useBangumiData) { - await initBangumiData(deployPlatform, isDataDependentRequest, ctx); - } - globals.deployPlatform = deployPlatform; if (deployPlatform === "node") { await judgeLocalCacheValid(path, deployPlatform); @@ -754,7 +747,10 @@ export default { // 获取客户端的真实 IP const clientIp = request.headers.get('cf-connecting-ip') || request.headers.get('x-forwarded-for') || 'unknown'; - return handleRequest(request, env, detectDeployPlatform(env), clientIp, ctx); + const response = await handleRequest(request, env, detectDeployPlatform(env), clientIp); + // 边缘运行时在响应返回后延长生命周期,容纳可能在途的 Bangumi Data 后台静默下载 + extendBangumiDownloadLifecycle(ctx); + return response; }, }; diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 3b4f142c7..575463a12 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -2850,6 +2850,101 @@ test('worker.js API endpoints', async (t) => { // }); // }); +// // 测试 Bangumi Data 数据下载时机(ensureBangumiDataReady)、配置变更触发下载(syncBangumiDataLifecycleOnConfigChange) +// // 以及 getTMDBChineseTitle 漏写 await 的修复;与 envs RAW_ENV_KEYS 测试同为按需启用的内部测试 +// import { globals } from './configs/globals.js'; +// import { ensureBangumiDataReady, syncBangumiDataLifecycleOnConfigChange, initBangumiData, clearBangumiDataCache } from './utils/bangumi-data-util.js'; +// import fs from 'node:fs'; +// import path from 'node:path'; +// +// test('bangumi-data 数据下载时机与配置变更触发下载', async (t) => { +// const CACHE_DIR = path.join(process.cwd(), '.cache'); +// const CACHE_FILE = path.join(CACHE_DIR, 'bangumi-data-cache.json'); +// const FAKE_ITEM = { +// title: 'FrobeniusTestAnime', +// titleTranslate: { 'zh-Hans': ['弗罗贝尼乌斯测试动画', 'FrobeniusTestAnime'] }, +// sites: [{ site: 'tmdb', id: '999999' }], +// _flatText: 'frobeniustestanime' +// }; +// const reset = () => { +// globals.useBangumiData = false; +// clearBangumiDataCache(false); +// if (fs.existsSync(CACHE_FILE)) fs.writeFileSync(CACHE_FILE, '', 'utf-8'); +// }; +// +// await t.test('ensureBangumiDataReady 开关关闭时直接返回且不触发下载', async () => { +// reset(); +// globals.useBangumiData = false; +// await ensureBangumiDataReady('node'); +// assert.ok(true); +// }); +// +// await t.test('syncBangumiDataLifecycleOnConfigChange 开关关闭释放缓存、开启安全触发', async () => { +// reset(); +// globals.useBangumiData = false; +// assert.doesNotThrow(() => syncBangumiDataLifecycleOnConfigChange('node')); +// fs.mkdirSync(CACHE_DIR, { recursive: true }); +// fs.writeFileSync(CACHE_FILE, JSON.stringify({ items: [FAKE_ITEM] }), 'utf-8'); +// globals.useBangumiData = true; +// assert.doesNotThrow(() => syncBangumiDataLifecycleOnConfigChange('node')); +// }); +// +// await t.test('getTMDBChineseTitle 经 await 命中本地中文名(修复漏写 await)', async () => { +// reset(); +// globals.useBangumiData = true; +// const originalContent = fs.existsSync(CACHE_FILE) ? fs.readFileSync(CACHE_FILE, 'utf-8') : null; +// fs.mkdirSync(CACHE_DIR, { recursive: true }); +// fs.writeFileSync(CACHE_FILE, JSON.stringify({ items: [FAKE_ITEM] }), 'utf-8'); +// try { +// await initBangumiData('node', true); +// const result = await getTMDBChineseTitle('FrobeniusTestAnime'); +// assert.equal(result, '弗罗贝尼乌斯测试动画'); +// } finally { +// clearBangumiDataCache(false); +// if (originalContent !== null) fs.writeFileSync(CACHE_FILE, originalContent, 'utf-8'); +// else fs.writeFileSync(CACHE_FILE, '', 'utf-8'); +// } +// }); +// }); + +// // 测试 Bangumi Data 在途下载暴露与边缘生命周期延长(getBackgroundDownload / extendBangumiDownloadLifecycle) +// // 与上方 bangumi 测试同为按需启用的内部测试;沙箱有网时真实下载以验证在途暴露、注册与清理 +// import { globals } from './configs/globals.js'; +// import { initBangumiData, getBackgroundDownload, extendBangumiDownloadLifecycle } from './utils/bangumi-data-util.js'; +// import assert from 'node:assert'; +// import fs from 'node:fs'; +// import path from 'node:path'; +// +// test('bangumi-data 在途下载暴露与边缘生命周期延长', async (t) => { +// const CACHE_DIR = path.join(process.cwd(), '.cache'); +// const CACHE_FILE = path.join(CACHE_DIR, 'bangumi-data-cache.json'); +// const hadCache = fs.existsSync(CACHE_DIR); +// +// // 空闲时无在途下载 +// assert.strictEqual(getBackgroundDownload(), null); +// +// await t.test('extendBangumiDownloadLifecycle 在无在途或 ctx 缺失时不注册', async () => { +// const calls = []; +// extendBangumiDownloadLifecycle(null); +// extendBangumiDownloadLifecycle({ waitUntil: (p) => calls.push(p) }); +// assert.strictEqual(calls.length, 0); +// }); +// +// await t.test('在途下载被暴露、响应后由边缘 waitUntil 注册、完成后清理', async () => { +// globals.useBangumiData = true; +// // 启动真实下载(无 .cache 时走内存路径,不落地文件;有 .cache 则后台刷新),不在途时立即返回 +// const initPromise = initBangumiData('node', true); +// const bg = getBackgroundDownload(); +// assert.ok(bg && typeof bg.then === 'function', '下载在途时应暴露 Promise'); +// const ctx = { waitUntil: (p) => { ctx.registered = p; } }; +// extendBangumiDownloadLifecycle(ctx); +// assert.strictEqual(ctx.registered, bg, '边缘 waitUntil 应注册在途 Promise'); +// await bg; // 等待下载完成(兼容阻塞与后台两种路径) +// assert.strictEqual(getBackgroundDownload(), null, '下载完成后应清理在途状态'); +// globals.useBangumiData = false; +// if (!hadCache && fs.existsSync(CACHE_FILE)) fs.writeFileSync(CACHE_FILE, '', 'utf-8'); +// await initPromise.catch(() => {}); +// }); // // 测试自定义文本类变量绕过 dotenv 注释截断(保留 # 等字符),对应 envs.js RAW_ENV_KEYS 修复 // import { Envs } from './configs/envs.js'; //