diff --git a/README.md b/README.md index e6c5743..badcade 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ LogVar 弹幕 API 服务器 - 弹幕缓存(可通过 `COMMENT_CACHE_MINUTES` 配置,默认5分钟) - 用户偏好记录(可通过 `MAX_LAST_SELECT_MAP` 配置,默认100条) - Redis 分布式缓存支持(可选) + - 本地和Docker部署支持实时保存缓存到文件(挂载.cache目录即可) - **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Edgeone 一键部署、Cloudflare 一键部署、Claw部署和 Docker 一键启动。 - **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。 - **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔播放链接可获取弹幕,如`senplayer`。 @@ -164,6 +165,7 @@ LogVar 弹幕 API 服务器 volumes: - ./.env:/app/.env - ./config.yaml:/app/config.yaml + - ./.chche:/app/.cache # 配置.chche目录,会将缓存实时保存在本地文件 restart: unless-stopped ``` @@ -491,6 +493,7 @@ danmu_api/ - TMDB源请求逻辑:search tmdb -> tmdbId -> imdbId -> doubanId -> playUrl;优点:emby通过tmdb刮削,标题通过tmdb搜索,返回的信息可能更加匹配;缺点:链条过长,请求时长5-10s左右,中间一环数据有缺失,就没有返回结果。 - TMDB源在SOURCE_ORDER添加tmdb的同时,需要添加TMDB_API_KEY环境变量 - 弹幕分片下载请求已加入重试机制,重试次数为1次 +- 如果同时配置了本地缓存和redis缓存,则以redis缓存优先 ### 关联项目 [喂饭教程1:danmu_api vercel 自动同步部署方案 - 永远保持最新版本!实时同步原作者更新](https://github.com/xiaoyao20084321/log-var-danmu-deployment-guide) diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 50bba54..ec2a533 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -4,7 +4,8 @@ import { log } from '../utils/log-util.js' import { setRedisKey, updateRedisCaches } from "../utils/redis-util.js"; import { setCommentCache, addAnime, findAnimeIdByCommentId, findTitleById, findUrlById, getCommentCache, getPreferAnimeId, - getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap + getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile, + updateLocalCaches } from "../utils/cache-util.js"; import { formatDanmuResponse } from "../utils/danmu-util.js"; import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces } from "../utils/common-util.js"; @@ -138,6 +139,10 @@ export async function searchAnime(url) { addAnime(Anime.fromJson({...tmpAnime, links: links})); if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + // 如果有新的anime获取到,则更新本地缓存 + if (globals.localCacheValid && curAnimes.length !== 0) { + await updateLocalCaches(); + } // 如果有新的anime获取到,则更新redis if (globals.redisValid && curAnimes.length !== 0) { await updateRedisCaches(); @@ -276,9 +281,13 @@ export async function searchAnime(url) { curAnimes.push(...validAnimes); } + // 如果有新的anime获取到,则更新本地缓存 + if (globals.localCacheValid && curAnimes.length !== 0) { + await updateLocalCaches(); + } // 如果有新的anime获取到,则更新redis if (globals.redisValid && curAnimes.length !== 0) { - await updateRedisCaches(); + await updateRedisCaches(); } // 缓存搜索结果 @@ -788,6 +797,9 @@ export async function getComment(path, queryFormat) { const animeId = findAnimeIdByCommentId(commentId); setPreferByAnimeId(animeId); + if (globals.localCacheValid && animeId) { + writeCacheToFile('lastSelectMap', JSON.stringify(Object.fromEntries(globals.lastSelectMap))); + } if (globals.redisValid && animeId) { await setRedisKey('lastSelectMap', globals.lastSelectMap); } diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index aed04a7..7e19411 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -11,7 +11,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.7.5', + VERSION: '1.7.6', MAX_LOGS: 500, // 日志存储,最多保存 500 行 MAX_ANIMES: 100, @@ -21,6 +21,8 @@ export const Globals = { episodeNum: 10001, // 全局变量,用于自增 ID logBuffer: [], requestHistory: new Map(), // 记录每个 IP 地址的请求历史 + localCacheValid: false, // 本地缓存是否生效 + localCacheInitialized: false, // 本地缓存是否已初始化 redisValid: false, // redis是否生效 redisCacheInitialized: false, // redis 缓存是否已初始化 lastSelectMap: new Map(), // 存储查询关键字上次选择的animeId,用于下次match自动匹配时优先选择该anime diff --git a/danmu_api/utils/cache-util.js b/danmu_api/utils/cache-util.js index dd83d65..d49740f 100644 --- a/danmu_api/utils/cache-util.js +++ b/danmu_api/utils/cache-util.js @@ -1,6 +1,10 @@ import { globals } from '../configs/globals.js'; import { log } from './log-util.js' import { Anime } from "../models/dandan-model.js"; +import { simpleHash } from "./codec-util.js"; +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; // ===================== // cache数据结构处理函数 @@ -303,4 +307,106 @@ export function cleanupExpiredIPs(currentTime) { if (cleanedCount > 0) { log("info", `[Rate Limit] Cleanup completed: removed ${cleanedCount} expired IP records`); } +} + +// 从本地缓存目录读取缓存数据 +export function readCacheFromFile(key) { + const cacheFilePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.cache', `${key}`); + if (fs.existsSync(cacheFilePath)) { + const fileContent = fs.readFileSync(cacheFilePath, 'utf8'); + return JSON.parse(fileContent); + } + return null; +} + +// 将缓存数据写入本地缓存文件 +export function writeCacheToFile(key, value) { + const cacheFilePath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.cache', `${key}`); + fs.writeFileSync(cacheFilePath, JSON.stringify(value), 'utf8'); +} + +// 从本地获取缓存 +export async function getLocalCaches() { + if (!globals.localCacheInitialized) { + try { + log("info", 'getLocalCaches start.'); + + // 从本地缓存文件读取数据并恢复到 globals 中 + globals.animes = JSON.parse(readCacheFromFile('animes')) || globals.animes; + globals.episodeIds = JSON.parse(readCacheFromFile('episodeIds')) || globals.episodeIds; + globals.episodeNum = JSON.parse(readCacheFromFile('episodeNum')) || globals.episodeNum; + + // 恢复 lastSelectMap 并转换为 Map 对象 + const lastSelectMapData = readCacheFromFile('lastSelectMap'); + if (lastSelectMapData) { + globals.lastSelectMap = new Map(Object.entries(JSON.parse(lastSelectMapData))); + log("info", `Restored lastSelectMap from local cache with ${globals.lastSelectMap.size} entries`); + } + + // 更新哈希值 + globals.lastHashes.animes = simpleHash(JSON.stringify(globals.animes)); + globals.lastHashes.episodeIds = simpleHash(JSON.stringify(globals.episodeIds)); + globals.lastHashes.episodeNum = simpleHash(JSON.stringify(globals.episodeNum)); + globals.lastHashes.lastSelectMap = simpleHash(JSON.stringify(Object.fromEntries(globals.lastSelectMap))); + + globals.localCacheInitialized = true; + log("info", 'getLocalCaches completed successfully.'); + } catch (error) { + log("error", `getLocalCaches failed: ${error.message}`, error.stack); + globals.localCacheInitialized = true; // 标记为已初始化,避免重复尝试 + } + } +} + +// 更新本地缓存 +export async function updateLocalCaches() { + try { + log("info", 'updateLocalCaches start.'); + const updates = []; + + // 检查每个变量的哈希值 + const variables = [ + { key: 'animes', value: globals.animes }, + { key: 'episodeIds', value: globals.episodeIds }, + { key: 'episodeNum', value: globals.episodeNum }, + { key: 'lastSelectMap', value: globals.lastSelectMap } + ]; + + for (const { key, value } of variables) { + // 对于 lastSelectMap(Map 对象),需要转换为普通对象后再序列化 + const serializedValue = key === 'lastSelectMap' ? JSON.stringify(Object.fromEntries(value)) : JSON.stringify(value); + const currentHash = simpleHash(serializedValue); + if (currentHash !== globals.lastHashes[key]) { + writeCacheToFile(key, serializedValue); + updates.push({ key, hash: currentHash }); + } + } + + // 输出更新日志 + if (updates.length > 0) { + log("info", `Updated local caches for keys: ${updates.map(u => u.key).join(', ')}`); + updates.forEach(({ key, hash }) => { + globals.lastHashes[key] = hash; // 更新本地哈希 + }); + } else { + log("info", 'No changes detected, skipping local cache update.'); + } + + } catch (error) { + log("error", `updateLocalCaches failed: ${error.message}`, error.stack); + log("error", `Error details - Name: ${error.name}, Cause: ${error.cause ? error.cause.message : 'N/A'}`); + } +} + +// 判断是否有效的本地缓存目录 +export function judgeLocalCacheValid(urlPath) { + if (!globals.localCacheValid && urlPath !== "/favicon.ico" && urlPath !== "/robots.txt") { + const cacheDirPath = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '.cache'); + + if (fs.existsSync(cacheDirPath)) { + globals.localCacheValid = true; + } else { + globals.localCacheValid = false; + } + } } \ No newline at end of file diff --git a/danmu_api/worker.js b/danmu_api/worker.js index a3ed6d2..7e06119 100644 --- a/danmu_api/worker.js +++ b/danmu_api/worker.js @@ -2,7 +2,7 @@ import { Globals } from './configs/globals.js'; import { jsonResponse } from './utils/http-util.js'; import { log, formatLogMessage } from './utils/log-util.js' import { getRedisCaches, judgeRedisValid } from "./utils/redis-util.js"; -import { cleanupExpiredIPs, findUrlById, getCommentCache } from "./utils/cache-util.js"; +import { cleanupExpiredIPs, findUrlById, getCommentCache, getLocalCaches, judgeLocalCacheValid } from "./utils/cache-util.js"; import { formatDanmuResponse } from "./utils/danmu-util.js"; import { getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, searchEpisodes } from "./apis/dandan-api.js"; @@ -16,12 +16,16 @@ async function handleRequest(req, env, deployPlatform, clientIp) { let path = url.pathname; const method = req.method; + judgeLocalCacheValid(path), await judgeRedisValid(path); log("info", `request url: ${JSON.stringify(url)}`); log("info", `request path: ${path}`); log("info", `client ip: ${clientIp}`); + if (globals.localCacheValid && path !== "/favicon.ico" && path !== "/robots.txt") { + await getLocalCaches(); + } if (globals.redisValid && path !== "/favicon.ico" && path !== "/robots.txt") { await getRedisCaches(); } @@ -33,6 +37,7 @@ async function handleRequest(req, env, deployPlatform, clientIp) { version: globals.VERSION, envs: { ...globals.accessedEnvVars, + localCacheValid: globals.localCacheValid, redisValid: globals.redisValid }, repository: "https://github.com/huangxd-/danmu_api.git",