feat: 添加永久收藏搜索结果缓存功能 (#426)

* feat: 添加永久收藏缓存功能,支持手动收藏/刷新/删除

- 新增 `/api/v2/favorite/add`、`/list`、`/refresh`、`/remove` 接口,用于管理永久收藏的搜索关键词及整组搜索结果
- 搜索时若命中收藏缓存(含包含关系匹配)则直接返回,不再请求外部弹幕源,提升重复搜索效率
- 收藏缓存无 TTL 和数量上限,Node/Docker 部署可持久化至 `.cache/favoritesCache`,serverless 仅内存保留
- 更新 README 文档,补充收藏功能说明、API 用法及 UI 操作指引
- 扩展搜索函数支持 `forceRefresh` 参数,刷新时绕过所有缓存重新查询
- 同步调整普通搜索缓存逻辑,确保收藏优先且不受常规缓存过期清理影响

* feat: 完善 Redis 持久化支持,收藏和查询信息可跨实例恢复

- 更新文档,明确 Redis 仅持久化原有查询信息和收藏缓存,搜索结果与弹幕缓存仍仅存于内存
- 收藏操作后同步写入 Redis,避免 serverless 冷启动丢失收藏
- 修复 `favoriteCache` 在 Redis 中的序列化问题,支持 Map 类型正确转换
- 优化 Redis 缓存更新逻辑,确保收藏与查询信息一致持久化

* feat: 添加收藏定时刷新功能

新增 `/api/v2/favorite/schedule` 接口,支持为收藏设置每日或每周定时刷新(需指定时间和星期),关闭时传入 `schedule: null`。仅 Node/Docker 部署生效,serverless 平台返回 501。定时刷新计划持久化保存,失败后 10 分钟重试一次,服务重启后补执行一次。同步更新 README 文档、收藏列表返回 `scheduledRefreshSupported` 标志,并导出 `persistFavorites` 供调度模块调用。

* feat(redis): 新增收藏缓存实时刷新机制以解决多实例缓存不一致

在 serverless 多实例部署场景下,新增 getFavoriteCachesFromRedis 函数,在收藏相关请求时直接从 Redis 重新读取收藏数据并更新内存缓存,避免预热实例因首次加载空快照而错过其他实例新增的收藏内容。

* feat(favorite): 收藏刷新增加确认弹窗并明确缓存范围

- 收藏刷新操作前增加确认对话框,避免误操作
- 完善UI说明,明确只缓存剧集搜索结果,不缓存弹幕
- 为定时收藏按钮添加禁用态样式,提升交互一致性

* feat: 收藏增加最近刷新时间字段并支持按刷新时间排序

- 在收藏条目中新增 `lastRefreshAt` 字段,记录最近一次刷新时间;新增收藏时初始化为当前时间,刷新时更新为该次刷新时间。
- UI 中展示最近刷新时间,并调整收藏列表排序依据从收藏时间改为最近刷新时间。
- 更新 README 及 UI 说明文档,同步接口返回字段和界面显示描述。
- 兼容旧缓存,若无 `lastRefreshAt` 则回退为 `timestamp` 值。

* feat(ui): 将管理界面默认主题从 shinyo 改为 lavender

- 调整 UI_THEME 环境变量默认值为 lavender
- 将 lavender 置于主题列表首位,其余主题顺序保持不变
- 更新 resolveUiTheme 回退默认值及环境变量配置中的默认值
- README 文档同步更新默认主题说明

此变更使经典默认主题在首次访问时优先展示,提升用户体验一致性。
This commit is contained in:
huangxd- authored and GitHub committed 2026-08-08 22:16:56 +08:00
1 parent 8ff78add1e
commit 838b5685ac
21 files changed
+1663 -48

No files matched your search

+25 -4
View File
@@ -62,6 +62,11 @@ LogVar 弹幕 API 服务器
- `GET /danmaku/api/v2/fongmi/danmaku?name={name}&episode={episode}`:兼容FengMi影视api短路径。
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- `GET /api/cache/animes`:获取最近的 animes 缓存。
- `POST /api/v2/favorite/add`:新增收藏。手动匹配测试使用 `{ "keyword": "火影忍者" }` 保存搜索关键词及整组搜索结果;同时兼容 `{ "fileName": "火影忍者 S01E01" }`。
- `GET /api/v2/favorite/list`:获取收藏摘要列表,包含收藏关键词、来源、总集数、首条搜索结果图片、收藏时间及最近刷新时间。
- `POST /api/v2/favorite/refresh`:使用 `{ "keyword": "火影忍者" }` 强制重新搜索并更新收藏缓存。
- `POST /api/v2/favorite/schedule`:设置或关闭收藏的定时刷新(仅 Node/Docker 部署可用)。设置使用 `{ "keyword": "火影忍者", "schedule": { "frequency": "daily", "time": "03:00" } }`;每周模式需额外传 `"weekday": 1-7`(周一至周日),例如 `{ "frequency": "weekly", "time": "03:00", "weekday": 1 }`。关闭使用 `{ "keyword": "火影忍者", "schedule": null }`。固定按北京时间(`Asia/Shanghai`)执行,serverless 平台返回 `501`。
- `POST /api/v2/favorite/remove`:使用 `{ "keyword": "火影忍者" }` 删除收藏及对应搜索缓存。
- **弹幕格式输出**:支持 JSON 和 XML 及 [@dan-uni/dan-any](https://github.com/ani-uni/dan-any)支持的全部输出格式 输出,通过以下方式配置:
- 环境变量:`DANMU_OUTPUT_FORMAT=json|xml|artplayer.json|baha.json|bili.xml|danuni.json|danuni.binpb|ddplay.json|dplayer.json|vod.json`(默认:json)
- 查询参数:`?format=xml` 或 `?format=json` ...(优先级最高)
@@ -69,11 +74,23 @@ LogVar 弹幕 API 服务器
- 示例:`GET /api/v2/comment/10001?format=xml` 返回 XML 格式弹幕
- **XML 格式说明**:完全遵循 Bilibili 标准格式,8字段标准弹幕属性
- **日志记录**:捕获 `console.log`(info 级别)和 `console.error`(error 级别),JSON 内容格式化输出。
- **永久收藏缓存**:适合《火影忍者》《名侦探柯南》等集数较多、重复搜索耗时较长的剧集。只缓存剧集搜索结果,不缓存弹幕。
- 在 UI 的“接口调试 → 弹幕测试 → 手动匹配测试”中输入关键词并搜索,搜索成功后点击“收藏”按钮即可保存整组搜索结果;已收藏时可再次点击按钮取消收藏。
- 收藏名称和缓存键使用手动搜索框中的原始关键词,例如搜索“火影忍者”即收藏为“火影忍者”;列表图片取第一条搜索结果的图片。
- 后续搜索或自动匹配命中收藏时直接从永久缓存返回,不再请求外部弹幕源;除精确关键词外,也会使用收藏剧名包含关系匹配同名剧场版、季度等变体。
- “收藏”标签页支持搜索收藏、强制刷新和删除,每项会显示收藏时间及最近刷新时间。刷新会绕过已有缓存重新查询;删除会同时移除对应搜索缓存。
- 收藏不受 `SEARCH_CACHE_MINUTES`、普通搜索缓存 500 条上限或过期清理影响。
- 支持定时刷新收藏:在“收藏”标签页点击“定时刷新”按钮,选择每天或每周(1-7 对应周一至周日)与执行时间,固定按北京时间(`Asia/Shanghai`)运行;已配置的条目按钮会显示类似“每天 03:00”“周一 03:00”,条目下方显示下次执行时间和最近状态。
- 定时刷新失败会保留旧缓存并在 10 分钟后自动重试一次,仍失败则等待下一个正常周期,不再继续重试;服务停机错过执行时间时,重启后只补执行一次并重新计算下一周期。
- 定时刷新计划随收藏一起保存在 `.cache/favoritesCache` 或 Redis 中,Node/Docker 重启后如需保留请挂载 `.cache` 目录或配置 Upstash Redis;纯内存收藏及计划会随进程重启丢失。Vercel、Cloudflare、Netlify、EdgeOne、Hugging Face 等 serverless 平台不启动调度器,按钮会禁用并提示“仅支持 Node/Docker 部署”。
- Node/Docker 部署会写入 `.cache/favoritesCache` 永久保存,请挂载 `.cache` 目录;serverless 未配置 Redis 时仅保存在当前实例内存中,配置 Redis 后可跨冷启动和实例恢复。
- **智能缓存管理**:支持内存缓存搜索结果和弹幕数据,避免短期内重复的不必要API请求。包括:
- 搜索结果缓存(可通过 `SEARCH_CACHE_MINUTES` 配置,默认1分钟)
- 弹幕缓存(可通过 `COMMENT_CACHE_MINUTES` 配置,默认5分钟)
- 永久收藏缓存(无 TTL、无数量上限,Node/Docker 可持久化到 `.cache/favoritesCache`)
- 用户偏好记录(可通过 `MAX_LAST_SELECT_MAP` 配置,默认100条)
- Redis 分布式缓存支持,包括本地redis和upstash redis(可选)
- 配置redis可持久化原有查询信息和永久收藏;搜索结果与弹幕缓存仍只保存在实例内存中,不会写入 Redis
- 本地和Docker部署支持实时保存缓存到文件(挂载.cache目录即可)
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Edgeone 一键部署、Cloudflare 一键部署、Hugging Face Spaces部署和 Docker 一键启动。
- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime,支持记住集episode,下次自动匹配时会对集进行偏移【实验性】。
@@ -95,6 +112,7 @@ LogVar 弹幕 API 服务器
- 配置预览
- 日志查看
- 接口调试/弹幕测试
- 自动匹配测试、手动匹配测试及收藏管理
- 推送弹幕
- 请求记录
- 系统管理
@@ -457,7 +475,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式)及所有`@dan-uni/dan-any`支持的输出格式,支持通过查询参数`?format=xml`或`?format=json`等覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 |
| DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) |
| DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒(不指定来源则对所有来源生效),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02@bilibili:120, re-zero/S02/E03@dandan&bilibili:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。支持百分比模式,在路径/来源末尾添加 `%`,例如:`东方/S03/E02@tencent%:11`,按 `原时间 * (视频时长 + 偏移秒数) / 视频时长` 计算新的弹幕发送时间。 |
| UI_THEME | 【可选】管理界面默认主题,默认为 `shinyo`(新叶绿)。浏览器中选择的主题会保存在本地并优先使用。可选值:`shinyo`(新叶绿)、`sakura`(哔哩粉)、`tianyi`(天依蓝)、`hatsune`(初音青)、`sakuragi`(樱木红)、`violet`(罗兰紫)、`amber`(LCL橘)、`lavender`(经典默认) |
| UI_THEME | 【可选】管理界面默认主题,默认为 `lavender`(经典默认)。浏览器中选择的主题会保存在本地并优先使用。可选值:`lavender`(经典默认)、`shinyo`(新叶绿)、`sakura`(哔哩粉)、`tianyi`(天依蓝)、`hatsune`(初音青)、`sakuragi`(樱木红)、`violet`(罗兰紫)、`amber`(LCL橘) |
| PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili、animeko生效,支持格式:<br> 正常代理:`http://127.0.0.1:7890` <br> 万能反代:`@http://127.0.0.1` <br> 特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili,animeko`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)<br> 混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址` <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了 bahamut / animeko 源或 TMDB API ,请配置代理/反代地址(animeko 也可通过开启 Bangumi Data 解决)([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 |
| TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后并行从TMDB获取日语原名搜索巴哈(如果TMDB条目类型不是动画或制作地区不是jp则不会进行巴哈搜索)可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) |
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
@@ -471,8 +489,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
| MAX_LAST_SELECT_MAP | 【可选】最后选择映射缓存大小限制,默认为`100`,lastSelectMap最多保存的条目数,超过限制时删除最早的条目(FIFO),用于存储查询关键字上次选择的animeId,最小值100,最大值1000 |
| MAX_ANIMES | 【可选】动漫标题缓存最大数量,默认为`100`,缓存最多保存的anime条目数,超过限制时删除最早的条目(FIFO),最小值100,最大值1000 |
| BANGUMI_DATA_CACHE_DAYS | 【可选】指定 Bangumi Data 数据有效期(天),默认为:`7`,超过有效期后会下载更新,设置0则每次请求时强制异步更新(需开启`USE_BANGUMI_DATA`)' |
| UPSTASH_REDIS_REST_URL | 【可选】Upstash redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| UPSTASH_REDIS_REST_TOKEN | 【可选】Upstash redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| UPSTASH_REDIS_REST_URL | 【可选】Upstash redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化原有查询信息和收藏缓存,避免 serverless 冷启动丢失收藏;搜索结果和弹幕缓存不会写入 Redis(会稍微影响收藏操作和冷启动请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| UPSTASH_REDIS_REST_TOKEN | 【可选】Upstash redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化原有查询信息和收藏缓存,避免 serverless 冷启动丢失收藏;搜索结果和弹幕缓存不会写入 Redis(会稍微影响收藏操作和冷启动请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| LOCAL_REDIS_URL | 【可选】本地Redis连接URL,用于本地缓存存储,适用于docker和本地部署环境,格式:`redis://:password@127.0.0.1:6379/0`,默认为空(不使用本地Redis) |
| DEPLOY_PLATFROM_ACCOUNT | 【可选】部署账号ID,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
| DEPLOY_PLATFROM_PROJECT | 【可选】部署项目名称,调用部署服务API需要,配置后可使用UI界面配置服务,不同部署平台获取方式可查看 [部署平台环境变量配置指南](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md#部署平台环境变量配置指南) ,docker部署和本地node部署并不需要配置 |
@@ -617,6 +635,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ │ │ └── fongmi-api.js # FongMi影视兼容接口
│ │ ├── dandan-api.js # 弹弹play兼容接口函数
│ │ ├── env-api.js # 环境变量接口函数
│ │ ├── favorite-api.js # 永久收藏的新增、列表、刷新、删除和定时刷新接口
│ │ ├── forward-trace-api.js # Forward 调试日志回传接口
│ │ └── system-api.js # 系统管理接口函数
│ ├── configs/
@@ -686,6 +705,8 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
│ ├── dan-any.js # dan-any 弹幕格式转换工具
│ ├── danmu-util.js # 弹幕处理工具
│ ├── douban-util.js # 豆瓣API请求工具
│ ├── favorite-schedule-util.js # 定时刷新的校验、时间计算与调度工具
│ ├── favorite-util.js # 永久收藏缓存的匹配、增删、刷新及序列化工具
│ ├── hanjutv-util.js # 韩剧tv加解密工具
│ ├── http-util.js # 请求工具
│ ├── imdb-util.js # IMDB API请求工具
@@ -721,7 +742,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
### 其他注意事项
- 日志存储在内存中,服务器重启后会清空。
- `/api/logs` 中的 JSON 日志会格式化显示,带缩进以提高可读性。
- 搜索结果和弹幕数据存储在内存中,服务器重启后会清空,可通过配置 `UPSTASH_REDIS_REST_URL` 和 `UPSTASH_REDIS_REST_TOKEN` 启用 Redis 持久化存储。
- 搜索结果和弹幕数据存储在内存中,服务器重启后会清空,可通过配置 `UPSTASH_REDIS_REST_URL` 和 `UPSTASH_REDIS_REST_TOKEN` 启用 Redis 持久化存储,启用 Redis 后,收藏功能也可用。
- 已支持本地redis,可通过配置 `LOCAL_REDIS_URL` 启用,只支持docker和本地部署环境。
- 搜索结果缓存默认时间为 1 分钟,可通过环境变量 `SEARCH_CACHE_MINUTES` 调整(设置为 0 表示不缓存)。
- 确保 `package.json` 中包含 `node-fetch` 依赖。
+22 -3
View File
@@ -9,6 +9,7 @@ import {
getSearchCache, removeEarliestAnime, resolveAnimeById, resolveAnimeByIdFromDetailStore, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile,
updateLocalCaches, setLastSearch, getLastSearch, findAnimeTitleById, findIndexById
} from "../utils/cache-util.js";
import { resolveFavoriteForKeyword } from "../utils/favorite-util.js";
import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js";
import { resolveOffset, resolveOffsetRule, applyOffset } from "../utils/offset-util.js";
import {
@@ -478,7 +479,7 @@ async function executeSourceHandlers(resultData, queryTitle, targetAnimesList, r
}
// Extracted function for GET /api/v2/search/anime
export async function searchAnime(url, preferAnimeId = null, preferSource = null, detailStore = null, targetPlatform = null) {
export async function searchAnime(url, preferAnimeId = null, preferSource = null, detailStore = null, targetPlatform = null, forceRefresh = false) {
let queryTitle = url.searchParams.get("keyword");
// 搜索词杂音清理:移除画质/配音/版本等杂音词后再提交源站搜索
@@ -512,8 +513,19 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null
const requestAnimeDetailsMap = detailStore instanceof Map ? detailStore : new Map();
const cacheKey = querySeason !== null ? `${queryTitle}_S${querySeason}` : queryTitle;
// 检查搜索缓存
let cachedResults = getSearchCache(cacheKey, requestAnimeDetailsMap);
// 收藏缓存命中后必须直接返回,不能因目标集数判断继续请求外部源。
if (!forceRefresh && resolveFavoriteForKeyword(cacheKey)) {
const favoriteResults = getSearchCache(cacheKey, requestAnimeDetailsMap) || [];
return jsonResponse({
errorCode: 0,
success: true,
errorMessage: "",
animes: favoriteResults,
});
}
// 检查普通搜索缓存;刷新收藏时显式跳过所有缓存。
let cachedResults = forceRefresh ? null : getSearchCache(cacheKey, requestAnimeDetailsMap);
// 如果带季度的特定缓存未命中,尝试获取不带季度的通用搜索缓存
if (cachedResults === null && querySeason !== null) {
@@ -1828,6 +1840,13 @@ export async function matchAnime(url, req, clientIp) {
]
}
if (resData["matches"] && resData["matches"].length > 0) {
const favoriteKey = season !== null ? `${title}_S${season}` : title;
if (resolveFavoriteForKeyword(favoriteKey)) {
resData["matches"] = resData["matches"].map(m => ({ ...m, isFavorite: true }));
}
}
log("info", `[system] [match] resMatchData: ${resData}`);
// 示例返回
+283
View File
@@ -0,0 +1,283 @@
import { globals } from '../configs/globals.js';
import { getSearchCache, updateLocalCaches } from '../utils/cache-util.js';
import { jsonResponse } from '../utils/http-util.js';
import { log } from '../utils/log-util.js';
import { simplized } from '../utils/zh-util.js';
import { parseFileName } from '../utils/common-util.js';
import {
addFavorite,
listFavorites,
refreshFavorite,
removeFavorite,
resolveFavoriteForKeyword,
stripSeasonSuffix
} from '../utils/favorite-util.js';
import { extractTitleSeasonEpisode, searchAnime } from './dandan-api.js';
import { createFavoriteSchedule } from '../utils/favorite-schedule-util.js';
const favoriteRefreshLocks = new Set();
async function resolveTitleForFavorite(fileName) {
const { cleanFileName } = parseFileName(fileName);
let { title, season, episode, year } = await extractTitleSeasonEpisode(cleanFileName);
if (globals.titleMappingTable && globals.titleMappingTable.size > 0) {
title = globals.titleMappingTable.get(title) || title;
}
if (globals.animeTitleSimplified) title = simplized(title);
if (globals.titleNoiseFilter) title = title.replace(globals.titleNoiseFilter, '').trim();
return { title, season, episode, year };
}
function buildFavoriteSearchUrl(baseUrl, keyword, season, episode) {
const searchUrl = new URL(baseUrl);
searchUrl.pathname = searchUrl.pathname.replace(/\/api\/v2\/.*$/, '/api/v2/search/anime');
searchUrl.search = '';
searchUrl.searchParams.set('keyword', keyword || '');
if (season !== undefined && season !== null) searchUrl.searchParams.set('season', String(season));
if (episode !== undefined && episode !== null) searchUrl.searchParams.set('episode', String(episode));
return searchUrl;
}
function cacheKeyFor(title, season) {
return season !== null && season !== undefined ? `${title}_S${season}` : title;
}
function detailsFromMap(detailsMap) {
return [...new Set(detailsMap instanceof Map ? detailsMap.values() : [])];
}
export async function persistFavorites() {
if (globals.localCacheValid) await updateLocalCaches();
if (globals.redisValid) {
const { updateRedisCaches } = await import('../utils/redis-util.js');
await updateRedisCaches();
}
}
function removeRelatedSearchCaches(keyword) {
if (!(globals.searchCache instanceof Map)) return;
const baseTitle = stripSeasonSuffix(keyword);
for (const key of globals.searchCache.keys()) {
if (stripSeasonSuffix(key) === baseTitle) globals.searchCache.delete(key);
}
}
async function findSearchEntry(cacheKey, title, season, episode, url) {
const detailsMap = new Map();
const cachedResults = getSearchCache(cacheKey, detailsMap);
if (cachedResults !== null) {
return { results: cachedResults, details: detailsFromMap(detailsMap) };
}
const searchUrl = buildFavoriteSearchUrl(url, title, season, episode);
const searchResponse = await searchAnime(searchUrl, null, null, detailsMap);
const searchData = await searchResponse.json();
if (!searchData?.success || !Array.isArray(searchData.animes) || searchData.animes.length === 0) return null;
const stored = globals.searchCache instanceof Map ? globals.searchCache.get(cacheKey) : null;
return {
results: stored?.results || searchData.animes,
details: stored?.details || detailsFromMap(detailsMap)
};
}
export async function handleFavoriteAdd(req, url) {
try {
const body = await req.json();
const requestedKeyword = String(body?.keyword || '').trim();
const fileName = String(body?.fileName || '').trim();
if (!requestedKeyword && !fileName) {
return jsonResponse({ success: false, message: '缺少 keyword 或 fileName 参数' }, 400);
}
let title;
let season = null;
let episode = null;
if (requestedKeyword) {
title = requestedKeyword;
if (globals.animeTitleSimplified) title = simplized(title);
if (globals.titleNoiseFilter) title = title.replace(globals.titleNoiseFilter, '').trim();
} else {
({ title, season, episode } = await resolveTitleForFavorite(fileName));
}
if (!title) return jsonResponse({ success: false, message: '无法解析剧名' }, 400);
const cacheKey = cacheKeyFor(title, season);
const entry = await findSearchEntry(cacheKey, title, season, episode, url);
if (!entry?.results?.length) {
return jsonResponse({ success: false, message: '未找到该剧集搜索结果,无法收藏' }, 404);
}
const favoriteName = requestedKeyword || stripSeasonSuffix(cacheKey);
addFavorite(favoriteName, entry.results, entry.details);
await persistFavorites();
return jsonResponse({
success: true,
message: `已收藏「${favoriteName}」`,
keyword: favoriteName,
animeTitle: favoriteName,
imageUrl: entry.results[0]?.imageUrl || '',
isFavorite: true
});
} catch (error) {
log('error', `[favorite] add failed: ${error.message}`);
return jsonResponse({ success: false, message: `收藏失败: ${error.message}` }, 500);
}
}
export function handleFavoriteList() {
return jsonResponse({
success: true,
scheduledRefreshSupported: globals.deployPlatform === 'node',
favorites: listFavorites()
});
}
export async function handleFavoriteSchedule(req) {
if (globals.deployPlatform !== 'node') {
return jsonResponse({ success: false, message: '定时刷新仅支持 Node/Docker 部署' }, 501);
}
try {
const body = await req.json();
const keyword = String(body?.keyword || '').trim();
if (!keyword) return jsonResponse({ success: false, message: '缺少 keyword 参数' }, 400);
const resolved = resolveFavoriteForKeyword(keyword);
if (!resolved) return jsonResponse({ success: false, message: '未找到该收藏' }, 404);
if (body.schedule === null) {
resolved.entry.refreshSchedule = null;
await persistFavorites();
return jsonResponse({ success: true, message: '已关闭定时刷新', refreshSchedule: null });
}
const created = createFavoriteSchedule(body.schedule);
if (!created.valid) return jsonResponse({ success: false, message: created.message }, 400);
resolved.entry.refreshSchedule = created.value;
await persistFavorites();
return jsonResponse({
success: true,
message: '定时刷新设置成功',
refreshSchedule: created.value
});
} catch (error) {
log('error', `[favorite] schedule failed: ${error.message}`);
return jsonResponse({ success: false, message: `设置定时刷新失败: ${error.message}` }, 500);
}
}
export async function handleFavoriteRemove(req) {
try {
const body = await req.json();
let keyword = String(body?.keyword || body?.title || body?.fileName || '').trim();
if (!keyword) return jsonResponse({ success: false, message: '缺少 keyword 参数' }, 400);
if (body?.fileName && !body?.keyword && !body?.title) {
const parsed = await resolveTitleForFavorite(keyword);
keyword = cacheKeyFor(parsed.title, parsed.season);
}
const resolved = resolveFavoriteForKeyword(keyword);
if (!resolved || !removeFavorite(resolved.keyword)) {
return jsonResponse({ success: false, message: '未找到该收藏' }, 404);
}
removeRelatedSearchCaches(resolved.keyword);
await persistFavorites();
return jsonResponse({ success: true, message: '已删除收藏' });
} catch (error) {
log('error', `[favorite] remove failed: ${error.message}`);
return jsonResponse({ success: false, message: `删除收藏失败: ${error.message}` }, 500);
}
}
async function refreshFavoriteResolved(fileName, requestedKeyword, url) {
let title;
let season = null;
let episode = null;
let cacheKey;
if (fileName) {
({ title, season, episode } = await resolveTitleForFavorite(fileName));
cacheKey = cacheKeyFor(title, season);
} else {
const resolved = resolveFavoriteForKeyword(requestedKeyword);
cacheKey = resolved?.keyword || requestedKeyword;
title = stripSeasonSuffix(cacheKey);
}
if (!resolveFavoriteForKeyword(cacheKey)) {
const error = new Error('未找到该收藏');
error.status = 404;
throw error;
}
const detailsMap = new Map();
const searchUrl = buildFavoriteSearchUrl(url, title, season, episode);
const searchResponse = await searchAnime(searchUrl, null, null, detailsMap, null, true);
const searchData = await searchResponse.json();
if (!searchData?.success || !Array.isArray(searchData.animes) || searchData.animes.length === 0) {
const error = new Error('刷新失败:未找到该剧集搜索结果');
error.status = 404;
throw error;
}
const stored = globals.searchCache instanceof Map ? globals.searchCache.get(cacheKey) : null;
refreshFavorite(cacheKey, stored?.results || searchData.animes, stored?.details || detailsFromMap(detailsMap));
const animeTitle = searchData.animes[0]?.animeTitle || title;
return { cacheKey, animeTitle };
}
export async function refreshFavoriteByKeyword(keyword, url, { persist = true } = {}) {
const resolved = resolveFavoriteForKeyword(keyword);
const lockKey = resolved?.keyword || String(keyword || '').trim();
if (!lockKey) throw Object.assign(new Error('未找到该收藏'), { status: 404 });
if (favoriteRefreshLocks.has(lockKey)) {
throw Object.assign(new Error('该收藏正在刷新,请稍后再试'), { status: 409 });
}
favoriteRefreshLocks.add(lockKey);
try {
const result = await refreshFavoriteResolved('', lockKey, url);
if (persist) await persistFavorites();
return result;
} finally {
favoriteRefreshLocks.delete(lockKey);
}
}
export async function handleFavoriteRefresh(req, url) {
try {
const body = await req.json();
const fileName = String(body?.fileName || '').trim();
const requestedKeyword = String(body?.keyword || '').trim();
if (!fileName && !requestedKeyword) {
return jsonResponse({ success: false, message: '缺少 fileName 或 keyword 参数' }, 400);
}
let result;
if (requestedKeyword) {
result = await refreshFavoriteByKeyword(requestedKeyword, url);
} else {
const parsed = await resolveTitleForFavorite(fileName);
const lockKey = resolveFavoriteForKeyword(cacheKeyFor(parsed.title, parsed.season))?.keyword;
if (!lockKey) return jsonResponse({ success: false, message: '未找到该收藏' }, 404);
if (favoriteRefreshLocks.has(lockKey)) {
return jsonResponse({ success: false, message: '该收藏正在刷新,请稍后再试' }, 409);
}
favoriteRefreshLocks.add(lockKey);
try {
result = await refreshFavoriteResolved(fileName, '', url);
await persistFavorites();
} finally {
favoriteRefreshLocks.delete(lockKey);
}
}
return jsonResponse({ success: true, message: `已刷新收藏「${result.animeTitle}」` });
} catch (error) {
log('error', `[favorite] refresh failed: ${error.message}`);
return jsonResponse({ success: false, message: `刷新收藏失败: ${error.message}` }, error.status || 500);
}
}
+2 -2
View File
@@ -6,12 +6,12 @@ import { HandlerFactory } from "../configs/handlers/handler-factory.js";
import { clearBangumiDataCache, initBangumiData } from "../utils/bangumi-data-util.js";
const UI_THEMES = new Set([
'shinyo', 'sakura', 'tianyi', 'hatsune', 'sakuragi', 'violet', 'amber', 'lavender'
'lavender', 'shinyo', 'sakura', 'tianyi', 'hatsune', 'sakuragi', 'violet', 'amber'
]);
function resolveUiTheme(theme) {
const normalizedTheme = String(theme || '').toLowerCase();
return UI_THEMES.has(normalizedTheme) ? normalizedTheme : 'shinyo';
return UI_THEMES.has(normalizedTheme) ? normalizedTheme : 'lavender';
}
export function handleUI() {
+2 -2
View File
@@ -672,7 +672,7 @@ export class Envs {
'BANGUMI_DATA_CACHE_DAYS': { category: 'cache', type: 'number', description: 'Bangumi Data 缓存有效期(天),设置0则每次请求时强制异步更新,默认7天', min: 0, max: 30 },
// 系统配置
'UI_THEME': { category: 'system', type: 'select', options: ['shinyo', 'sakura', 'tianyi', 'hatsune', 'sakuragi', 'violet', 'amber', 'lavender'], description: '管理界面主题' },
'UI_THEME': { category: 'system', type: 'select', options: ['lavender', 'shinyo', 'sakura', 'tianyi', 'hatsune', 'sakuragi', 'violet', 'amber'], description: '管理界面主题' },
'PROXY_URL': { category: 'system', type: 'text', description: '代理/反代地址' },
'TMDB_API_KEY': { category: 'system', type: 'text', description: 'TMDB API密钥' },
'LOG_LEVEL': { category: 'system', type: 'select', options: ['debug', 'info', 'warn', 'error'], description: '日志级别配置' },
@@ -706,7 +706,7 @@ export class Envs {
blockedWords: this.get('BLOCKED_WORDS', '', 'string'), // 屏蔽词列表
groupMinute: Math.min(this.get('GROUP_MINUTE', 1, 'number'), 30), // 分钟内合并去重(默认 1,最大值30,0表示不去重)
danmuLimit: this.get('DANMU_LIMIT', 0, 'number'), // 等间隔采样限制弹幕总数,单位为k,即千:默认 0,表示不限制弹幕数,若改为5,弹幕总数在超过5000的情况下会将弹幕数控制在5000
uiTheme: this.get('UI_THEME', 'shinyo', 'string').toLowerCase(), // 管理界面主题
uiTheme: this.get('UI_THEME', 'lavender', 'string').toLowerCase(), // 管理界面主题
proxyUrl: this.get('PROXY_URL', '', 'string', true), // 代理/反代地址
danmuSimplifiedTraditional: this.get('DANMU_SIMPLIFIED_TRADITIONAL', 'default', 'string'), // 弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁)
danmuPushUrl: this.get('DANMU_PUSH_URL', '', 'string'), // 代理/反代地址
+3 -1
View File
@@ -38,10 +38,12 @@ export const Globals = {
episodeNum: null,
lastSelectMap: null,
reqRecords: null,
todayReqNum: null
todayReqNum: null,
favoriteCache: null
},
searchCache: new Map(), // 搜索结果缓存,存储格式:{ keyword: { results, timestamp } }
commentCache: new Map(), // 弹幕缓存,存储格式:{ videoUrl: { comments, timestamp } }
favoriteCache: new Map(), // 收藏剧集永久缓存,存储格式:{ keyword: { results, details, timestamp } },无 TTL、无数量上限
deployPlatform: '', // 部署平台配置
currentToken: '', // 标识当前可用token
+25
View File
@@ -11,6 +11,10 @@ import { Request as NodeFetchRequest } from 'node-fetch';
import { handleRequest } from './worker.js';
import { Globals } from './configs/globals.js';
import { clearBangumiDataCache, initBangumiData } 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';
import { startFavoriteScheduler, stopFavoriteScheduler } from './utils/favorite-schedule-util.js';
// =====================
// server.js - 本地node智能启动脚本:根据 Node.js 环境自动选择最优启动模式
@@ -244,6 +248,7 @@ async function setupEnvWatcher() {
* 优雅关闭:清理文件监听器并关闭服务器
*/
function cleanupWatcher() {
stopFavoriteScheduler();
if (envWatcher) {
console.log('[server] Closing file watcher...');
envWatcher.close();
@@ -499,6 +504,11 @@ async function startServer() {
mainServer = createServer();
mainServer.listen(mainPort, '0.0.0.0', () => {
console.log(`Server running on http://0.0.0.0:${mainPort}`);
if (detectNodeDeployPlatform() === 'node') {
initializeFavoriteScheduler(mainPort).catch(error => {
console.error('[server] Favorite scheduler initialization failed:', error.message);
});
}
});
// 启动5321端口的代理服务
@@ -511,5 +521,20 @@ async function startServer() {
});
}
async function initializeFavoriteScheduler(mainPort) {
await judgeLocalCacheValid('/api/v2/favorite/list', 'node');
if (Globals.localCacheValid) await getLocalCaches();
await judgeRedisValid('/api/v2/favorite/list');
if (Globals.redisValid) await getRedisCaches();
const refreshUrl = new URL(`http://127.0.0.1:${mainPort}/api/v2/favorite/refresh`);
await startFavoriteScheduler({
refresh: keyword => refreshFavoriteByKeyword(keyword, refreshUrl, { persist: false }),
persist: persistFavorites
});
console.log('[server] Favorite scheduler started (Node/Docker only, Asia/Shanghai)');
}
// 启动
startServer();
+78
View File
@@ -123,6 +123,7 @@ export const componentsCssContent = /* css */ `
.env-item .env-actions {
display: flex;
gap: 6px;
flex-wrap: wrap;
flex-shrink: 0;
}
@@ -164,6 +165,83 @@ export const componentsCssContent = /* css */ `
transform: scale(0.97);
}
.favorite-action-btn {
max-width: 260px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.favorite-action-btn:disabled,
.favorite-action-btn:disabled:hover,
body[data-theme] .favorite-action-btn:disabled,
body[data-theme] .favorite-action-btn:disabled:hover {
background: #d1d5db !important;
color: #6b7280 !important;
border-color: #d1d5db !important;
cursor: not-allowed;
opacity: 1;
box-shadow: none;
transform: none;
}
.favorite-action-btn:disabled::before {
display: none;
}
.favorite-cover {
width: 58px;
height: 78px;
flex: 0 0 auto;
border-radius: 6px;
object-fit: cover;
background: #e5e7eb;
}
.favorite-info-row {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.favorite-meta {
color: #6b7280;
font-size: 12px;
line-height: 1.7;
}
.favorite-schedule-btn {
min-width: 92px;
}
.favorite-schedule-btn:disabled,
.favorite-schedule-btn:disabled:hover,
body[data-theme] .favorite-schedule-btn:disabled,
body[data-theme] .favorite-schedule-btn:disabled:hover {
background: #d1d5db !important;
color: #6b7280 !important;
border-color: #d1d5db !important;
cursor: not-allowed;
opacity: 1;
box-shadow: none;
transform: none;
}
.favorite-schedule-hint {
color: #6b7280;
font-size: 13px;
margin: 0 0 16px;
}
.favorite-schedule-modal-content {
max-width: 460px;
}
.favorite-list {
margin-top: 8px;
}
.btn-primary {
background: linear-gradient(135deg, var(--theme-accent), var(--theme-accent-hover));
color: #ffffff;
+14
View File
@@ -92,6 +92,11 @@ export const responsiveCssContent = /* css */ `
width: 100%;
}
.favorite-action-btn {
max-width: none;
flex-basis: 100%;
}
.btn {
flex: 1;
}
@@ -195,6 +200,15 @@ export const responsiveCssContent = /* css */ `
width: calc(100% - 12px);
box-sizing: border-box;
}
.theme-settings {
flex-direction: column;
align-items: stretch;
}
.theme-options {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
/* ============ 小屏手机 (<= 480px) ============ */
+1 -1
View File
@@ -205,7 +205,7 @@ body[data-theme] .theme-option:focus-visible, body[data-theme] .btn:focus-visibl
.theme-settings-copy h3 { margin-bottom: 4px; font-size: 15px; }
.theme-settings-copy p { margin: 0; color: var(--theme-muted); font-size: 12px; }
.theme-current-label { color: var(--theme-accent); font-size: 12px; font-weight: 600; }
.theme-options { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 6px; flex: 1; }
.theme-options { display: grid; grid-template-columns: repeat(8, minmax(0, 1fr)); gap: 6px; flex: 1; }
.theme-option { display: flex; align-items: center; gap: 6px; min-height: 44px; padding: 6px 8px; border: 1px solid var(--theme-border); border-radius: 10px; cursor: pointer; text-align: left; transition: border-color 0.22s var(--app-ease-smooth), background 0.22s var(--app-ease-smooth), box-shadow 0.22s var(--app-ease-smooth); }
.theme-option:hover { border-color: var(--theme-accent); }
.theme-option:disabled { cursor: wait; opacity: 0.65; }
+364 -6
View File
@@ -76,9 +76,15 @@ let danmuTestState = {
nextDanmuFlowRequestId: 0,
activeDanmuFlowRequestId: 0,
nextManualBangumiRequestId: 0,
activeManualBangumiRequestId: 0
activeManualBangumiRequestId: 0,
favoriteSearchKeyword: '',
favoriteAnimeTitle: '',
favoriteKeyword: '',
isFavorite: false
};
let favoriteState = { items: [], loading: false, error: '', loaded: false, searchQuery: '', scheduledRefreshSupported: false };
// 初始化接口调试界面
function initApiTestInterface() {
const apiSelect = document.getElementById('api-select');
@@ -98,6 +104,7 @@ function initApiTestInterface() {
manualInput.addEventListener('keypress', function(event) {
if (event.key === 'Enter') { manualSearchAnime(); }
});
manualInput.addEventListener('input', resetManualFavoriteButton);
}
}
@@ -420,10 +427,11 @@ function switchDanmuTestTab(tab, event) {
document.querySelectorAll('.danmu-test-tab').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.danmu-test-panel').forEach(el => el.classList.remove('active'));
event.target.classList.add('active');
if (tab === 'auto') {
document.getElementById('auto-match-panel').classList.add('active');
} else {
document.getElementById('manual-match-panel').classList.add('active');
const panelId = tab === 'auto' ? 'auto-match-panel' : tab === 'manual' ? 'manual-match-panel' : 'favorite-panel';
document.getElementById(panelId).classList.add('active');
if (tab === 'favorite') {
document.getElementById('danmu-result-area').style.display = 'none';
loadFavoriteList();
}
}
@@ -723,6 +731,348 @@ function setDanmuFlowButtonLoading(btn, requestId, loading) {
// =====================
// 自动匹配测试
// =====================
function resetManualFavoriteButton() {
danmuTestState.favoriteSearchKeyword = '';
danmuTestState.favoriteAnimeTitle = '';
danmuTestState.favoriteKeyword = '';
danmuTestState.isFavorite = false;
const button = document.getElementById('manual-favorite-btn');
if (!button) return;
button.classList.remove('btn-danger');
button.classList.add('btn-success');
button.disabled = true;
button.textContent = '收藏';
button.title = '请先完成手动搜索';
}
function renderManualFavoriteButton() {
const button = document.getElementById('manual-favorite-btn');
if (!button || !danmuTestState.favoriteSearchKeyword) return;
const title = danmuTestState.favoriteAnimeTitle || danmuTestState.favoriteSearchKeyword;
button.disabled = false;
button.classList.toggle('btn-success', !danmuTestState.isFavorite);
button.classList.toggle('btn-danger', danmuTestState.isFavorite);
button.textContent = (danmuTestState.isFavorite ? '取消收藏 · ' : '收藏 · ') + title;
button.title = danmuTestState.isFavorite ? '取消收藏「' + title + '」' : '收藏「' + title + '」';
}
function setManualFavoriteButton(keyword) {
const animeTitle = keyword;
const normalizedKeyword = String(keyword || '').trim().toLocaleLowerCase();
const normalizedTitle = String(animeTitle || '').trim().toLocaleLowerCase();
const favorite = favoriteState.items.find(item => {
const favoriteKeyword = String(item.keyword || '').trim().toLocaleLowerCase();
const favoriteTitle = String(item.animeTitle || '').trim().toLocaleLowerCase();
return favoriteKeyword === normalizedKeyword || favoriteTitle === normalizedTitle ||
(favoriteKeyword.length >= 2 && normalizedKeyword.includes(favoriteKeyword)) ||
(normalizedKeyword.length >= 2 && favoriteKeyword.includes(normalizedKeyword));
});
danmuTestState.favoriteSearchKeyword = keyword;
danmuTestState.favoriteAnimeTitle = animeTitle;
danmuTestState.favoriteKeyword = favorite?.keyword || '';
danmuTestState.isFavorite = !!favorite;
const button = document.getElementById('manual-favorite-btn');
if (!button) return;
renderManualFavoriteButton();
}
async function favoriteManualSearch() {
const button = document.getElementById('manual-favorite-btn');
const inputKeyword = document.getElementById('manual-search-keyword')?.value.trim() || '';
if (!button || !inputKeyword) {
resetManualFavoriteButton();
return;
}
if (inputKeyword !== danmuTestState.favoriteSearchKeyword) {
resetManualFavoriteButton();
return;
}
const keyword = danmuTestState.favoriteSearchKeyword;
const removing = danmuTestState.isFavorite;
setBtnLoading(button, true);
try {
const response = await fetch(buildApiUrl(removing ? '/api/v2/favorite/remove' : '/api/v2/favorite/add'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword: removing ? danmuTestState.favoriteKeyword || keyword : keyword })
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || (removing ? '取消收藏失败' : '收藏失败'));
if (removing) {
const removedKeyword = danmuTestState.favoriteKeyword || keyword;
favoriteState.items = favoriteState.items.filter(item => item.keyword !== removedKeyword);
danmuTestState.isFavorite = false;
danmuTestState.favoriteKeyword = '';
} else {
danmuTestState.isFavorite = true;
danmuTestState.favoriteKeyword = data.keyword || keyword;
}
addLog(data.message || (removing ? '已取消收藏' : '收藏成功'), 'success');
await loadFavoriteList(false);
} catch (error) {
customAlert((removing ? '取消收藏失败: ' : '收藏失败: ') + error.message);
addLog((removing ? '取消收藏失败: ' : '收藏失败: ') + error.message, 'error');
} finally {
setBtnLoading(button, false);
renderManualFavoriteButton();
}
}
function formatFavoriteTime(timestamp) {
if (!timestamp) return '未知时间';
const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? '未知时间' : date.toLocaleString();
}
function formatFavoriteSchedule(schedule) {
if (!schedule) return '';
const frequency = schedule.frequency === 'weekly' ? '每周' : '每天';
const weekdayNames = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'];
const weekday = schedule.frequency === 'weekly' ? (weekdayNames[Number(schedule.weekday)] || '') : '';
const time = schedule.time || '--:--';
return frequency + (weekday ? ' ' + weekday : '') + ' ' + time;
}
function formatFavoriteNextRun(timestamp) {
if (!timestamp) return '';
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) return '';
return date.toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
}
function formatFavoriteLastStatus(schedule) {
if (!schedule) return '';
if (schedule.lastStatus === 'failed') return '上次失败' + (schedule.lastError ? ':' + schedule.lastError : '');
if (schedule.lastStatus === 'success') return '上次成功';
return '';
}
function normalizeFavoriteTime(value) {
const cleaned = String(value || '').trim();
if (!cleaned) return '';
const match = /^(\\d{1,2}):(\\d{2})(?::\\d{2})?$/.exec(cleaned);
if (!match) return '';
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return '';
return String(hour).padStart(2, '0') + ':' + match[2];
}
function renderFavoriteItem(item) {
const imageUrl = escapeHtml(item.imageUrl || '');
const keyword = escapeHtml(item.keyword || '');
const title = escapeHtml(item.animeTitle || item.keyword || '未命名剧集');
const source = escapeHtml(item.source || '未知来源');
const episodeText = Number(item.episodeCount) > 0 ? item.episodeCount + ' 集' : '集数未知';
const schedule = item.refreshSchedule;
const scheduleText = schedule ? formatFavoriteSchedule(schedule) : '定时刷新';
const scheduleTitle = favoriteState.scheduledRefreshSupported ? '设置定时刷新' : '定时刷新仅支持 Node/Docker 部署';
const scheduleDisabled = favoriteState.scheduledRefreshSupported ? '' : ' disabled';
const lastStatusText = formatFavoriteLastStatus(schedule);
const scheduleMeta = schedule
? '定时刷新:' + escapeHtml(formatFavoriteSchedule(schedule)) + (schedule.nextRunAt ? ' · 下次 ' + escapeHtml(formatFavoriteNextRun(schedule.nextRunAt)) : '') + (lastStatusText ? ' · ' + escapeHtml(lastStatusText) : '')
: '定时刷新:未设置';
return \`
<div class="env-item favorite-item">
<div class="env-info favorite-info-row">
\${imageUrl ? '<img class="favorite-cover" src="' + imageUrl + '" alt="" loading="lazy">' : ''}
<div>
<strong>\${title}</strong>
<div class="favorite-meta">来源:\${source} · \${episodeText} · \${item.resultsCount || 0} 个搜索结果</div>
<div class="favorite-meta">收藏时间:\${escapeHtml(formatFavoriteTime(item.timestamp))}</div>
<div class="favorite-meta">最近刷新时间:\${escapeHtml(formatFavoriteTime(item.lastRefreshAt || item.timestamp))}</div>
<div class="favorite-meta">\${scheduleMeta}</div>
</div>
</div>
<div class="env-actions">
<button class="btn \${schedule ? 'btn-success' : 'btn-primary'} favorite-schedule-btn" data-keyword="\${keyword}" onclick="openFavoriteScheduleModal(this.dataset.keyword)" title="\${scheduleTitle}"\${scheduleDisabled}>\${escapeHtml(scheduleText)}</button>
<button class="btn btn-primary" data-keyword="\${keyword}" onclick="refreshFavoriteItem(this.dataset.keyword, this)">刷新</button>
<button class="btn btn-danger" data-keyword="\${keyword}" data-title="\${title}" onclick="removeFavoriteItem(this.dataset.keyword, this.dataset.title, this)">删除</button>
</div>
</div>
\`;
}
function renderFavoriteListView() {
const list = document.getElementById('favorite-list');
const status = document.getElementById('favorite-list-status');
if (!list) return;
if (favoriteState.loading && !favoriteState.loaded) {
if (status) status.textContent = '收藏 · 正在加载';
list.innerHTML = '<p class="text-gray padding-20 text-center">正在加载收藏...</p>';
return;
}
if (favoriteState.error) {
if (status) status.textContent = '收藏 · 加载失败';
list.innerHTML = \`<div class="preview-empty"><strong>收藏加载失败</strong><span>\${escapeHtml(favoriteState.error)}</span></div>\`;
return;
}
const normalizedQuery = favoriteState.searchQuery.toLocaleLowerCase();
const items = normalizedQuery
? favoriteState.items.filter(item => [item.keyword, item.animeTitle, item.source].join(' ').toLocaleLowerCase().includes(normalizedQuery))
: favoriteState.items;
if (status) status.textContent = '收藏 · ' + items.length + ' 项';
list.innerHTML = items.length
? items.map(renderFavoriteItem).join('')
: '<div class="preview-empty"><strong>暂无收藏</strong><span>请先在手动匹配测试中添加收藏</span></div>';
}
function handleFavoriteSearch(event) {
favoriteState.searchQuery = event.target.value.trim();
renderFavoriteListView();
}
async function loadFavoriteList(shouldRender = true) {
favoriteState.loading = true;
favoriteState.error = '';
if (shouldRender) renderFavoriteListView();
try {
const response = await fetch(buildApiUrl('/api/v2/favorite/list'));
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || 'HTTP ' + response.status);
favoriteState.items = Array.isArray(data.favorites) ? data.favorites : [];
favoriteState.scheduledRefreshSupported = data.scheduledRefreshSupported === true;
favoriteState.loaded = true;
} catch (error) {
favoriteState.error = error.message;
addLog('收藏列表加载失败: ' + error.message, 'error');
} finally {
favoriteState.loading = false;
if (shouldRender) renderFavoriteListView();
}
}
function toggleFavoriteScheduleWeekday() {
const frequency = document.getElementById('favorite-schedule-frequency');
const group = document.getElementById('favorite-schedule-weekday-group');
if (group && frequency) group.style.display = frequency.value === 'weekly' ? '' : 'none';
}
function openFavoriteScheduleModal(keyword) {
if (!favoriteState.scheduledRefreshSupported) {
customAlert('定时刷新仅支持 Node/Docker 部署');
return;
}
const item = favoriteState.items.find(entry => entry.keyword === keyword);
const schedule = item?.refreshSchedule;
document.getElementById('favorite-schedule-keyword').value = keyword;
document.getElementById('favorite-schedule-frequency').value = schedule?.frequency || 'daily';
document.getElementById('favorite-schedule-weekday').value = String(schedule?.weekday || 1);
toggleFavoriteScheduleWeekday();
document.getElementById('favorite-schedule-modal').classList.add('active');
lockPageScroll();
// 弹窗显示后再填充时间,避免部分浏览器在隐藏状态下丢弃 time 输入框的值
document.getElementById('favorite-schedule-time').value = schedule?.time || '03:00';
}
function closeFavoriteScheduleModal() {
const modal = document.getElementById('favorite-schedule-modal');
if (modal) modal.classList.remove('active');
unlockPageScroll();
}
async function saveFavoriteSchedule() {
const keyword = document.getElementById('favorite-schedule-keyword').value;
const frequency = document.getElementById('favorite-schedule-frequency').value;
const time = normalizeFavoriteTime(document.getElementById('favorite-schedule-time').value);
const weekday = Number(document.getElementById('favorite-schedule-weekday').value);
if (!keyword || !time) {
customAlert('请选择有效的刷新时间');
return;
}
try {
const response = await fetch(buildApiUrl('/api/v2/favorite/schedule'), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, schedule: { frequency, time, ...(frequency === 'weekly' ? { weekday } : {}) } })
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || '设置失败');
closeFavoriteScheduleModal();
addLog(data.message || '定时刷新设置成功', 'success');
await loadFavoriteList();
} catch (error) {
customAlert('设置定时刷新失败: ' + error.message);
addLog('设置定时刷新失败: ' + error.message, 'error');
}
}
async function disableFavoriteSchedule() {
const keyword = document.getElementById('favorite-schedule-keyword').value;
if (!keyword) return;
const confirmed = await customConfirm('确定关闭该剧集的定时刷新吗?');
if (!confirmed) return;
try {
const response = await fetch(buildApiUrl('/api/v2/favorite/schedule'), {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword, schedule: null })
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || '关闭失败');
closeFavoriteScheduleModal();
addLog(data.message || '已关闭定时刷新', 'success');
await loadFavoriteList();
} catch (error) {
customAlert('关闭定时刷新失败: ' + error.message);
addLog('关闭定时刷新失败: ' + error.message, 'error');
}
}
async function refreshFavoriteItem(keyword, button) {
const item = favoriteState.items.find(entry => entry.keyword === keyword);
const title = item?.animeTitle || item?.keyword || '未命名剧集';
const confirmed = await customConfirm('确定刷新收藏「' + title + '」吗?');
if (!confirmed) return;
const originalText = button.textContent;
button.disabled = true;
button.textContent = '刷新中...';
try {
const response = await fetch(buildApiUrl('/api/v2/favorite/refresh'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword })
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || '刷新失败');
addLog(data.message || '收藏刷新成功', 'success');
await loadFavoriteList();
} catch (error) {
button.disabled = false;
button.textContent = originalText;
customAlert('刷新收藏失败: ' + error.message);
addLog('刷新收藏失败: ' + error.message, 'error');
}
}
async function removeFavoriteItem(keyword, title, button) {
const confirmed = await customConfirm('确定删除收藏「' + title + '」吗?');
if (!confirmed) return;
const originalText = button.textContent;
button.disabled = true;
button.textContent = '删除中...';
try {
const response = await fetch(buildApiUrl('/api/v2/favorite/remove'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ keyword })
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || '删除失败');
addLog(data.message || '收藏已删除', 'success');
await loadFavoriteList();
} catch (error) {
button.disabled = false;
button.textContent = originalText;
customAlert('删除收藏失败: ' + error.message);
addLog('删除收藏失败: ' + error.message, 'error');
}
}
async function autoMatchTest() {
const fileName = document.getElementById('auto-match-filename').value.trim();
if (!fileName) { customAlert('请输入文件名'); return; }
@@ -795,9 +1145,14 @@ async function autoMatchTest() {
// =====================
async function manualSearchAnime() {
const keyword = document.getElementById('manual-search-keyword').value.trim();
if (!keyword) { customAlert('请输入搜索关键字'); return; }
if (!keyword) {
resetManualFavoriteButton();
customAlert('请输入搜索关键字');
return;
}
const flowRequestId = startDanmuFlowRequest();
resetManualFavoriteButton();
const btn = document.getElementById('manual-search-btn');
setDanmuFlowButtonLoading(btn, flowRequestId, true);
document.getElementById('manual-anime-list').style.display = 'none';
@@ -829,6 +1184,9 @@ async function manualSearchAnime() {
});
danmuTestState.currentManualSearchCallTraceBase = cloneDanmuCallTrace(trace);
displayManualAnimeList(data.animes);
await loadFavoriteList(false);
if (!isCurrentDanmuFlowRequest(flowRequestId)) return;
setManualFavoriteButton(keyword);
addLog('搜索到 ' + data.animes.length + ' 个动漫', 'success');
} else {
finishDanmuCallEmpty(trace, searchStartedAt, {
+1 -1
View File
@@ -185,7 +185,7 @@ function loadEnvVariables() {
// 使用从API获取的原始环境变量,用于系统设置
const originalEnvVars = config.originalEnvVars || {};
// 浏览器偏好覆盖部署环境变量;云函数更新变量通常要等重新部署后才会进入新实例。
applyTheme(getStoredTheme() || originalEnvVars.UI_THEME || document.body.dataset.theme || 'shinyo');
applyTheme(getStoredTheme() || originalEnvVars.UI_THEME || document.body.dataset.theme || 'lavender');
// 恢复独立的深浅色偏好:已存储 > 系统偏好 > 默认浅色
if (!document.body.dataset.colorScheme) {
var scheme = getStoredColorScheme();
+4 -4
View File
@@ -5,6 +5,7 @@ let isMergeMode = false;
let stagingTags = [];
const UI_THEMES = {
lavender: '经典默认',
shinyo: '新叶绿',
sakura: '哔哩粉',
tianyi: '天依蓝',
@@ -12,7 +13,6 @@ const UI_THEMES = {
sakuragi: '樱木红',
violet: '罗兰紫',
amber: 'LCL橘',
lavender: '经典默认',
};
const UI_THEME_STORAGE_KEY = 'logvar_ui_theme';
@@ -41,7 +41,7 @@ function storeTheme(theme) {
function applyTheme(theme) {
const normalizedTheme = String(theme || '').toLowerCase();
const selectedTheme = Object.prototype.hasOwnProperty.call(UI_THEMES, normalizedTheme) ? normalizedTheme : 'shinyo';
const selectedTheme = Object.prototype.hasOwnProperty.call(UI_THEMES, normalizedTheme) ? normalizedTheme : 'lavender';
document.body.dataset.theme = selectedTheme;
document.querySelectorAll('[data-theme-option]').forEach(button => {
@@ -84,7 +84,7 @@ async function selectTheme(theme) {
}
}
applyTheme(getStoredTheme() || document.body.dataset.theme || 'shinyo');
applyTheme(getStoredTheme() || document.body.dataset.theme || 'lavender');
// 导出当前管理员可见的环境变量配置
async function exportSystemConfig() {
@@ -179,7 +179,7 @@ function normalizeImportedConfig(data) {
return;
}
if (key === 'UI_THEME') {
value = value.trim().toLowerCase() || 'shinyo';
value = value.trim().toLowerCase() || 'lavender';
if (!Object.prototype.hasOwnProperty.call(UI_THEMES, value)) {
invalidKeys.push(key + ' (不支持的主题: ' + value + ')');
return;
+57 -6
View File
@@ -35,7 +35,7 @@ export const HTML_TEMPLATE = /* html */ `
<script>
try {
var storedTheme = localStorage.getItem('logvar_ui_theme');
var validThemes = ['shinyo','sakura','tianyi','hatsune','sakuragi','violet','amber','lavender'];
var validThemes = ['lavender','shinyo','sakura','tianyi','hatsune','sakuragi','violet','amber'];
if (validThemes.indexOf(storedTheme) !== -1) { document.body.dataset.theme = storedTheme; }
var storedScheme = localStorage.getItem('logvar_ui_color_scheme');
if (storedScheme === 'dark' || storedScheme === 'light') {
@@ -176,13 +176,14 @@ export const HTML_TEMPLATE = /* html */ `
<div class="danmu-test-tabs">
<button class="danmu-test-tab active" onclick="switchDanmuTestTab('auto', event)">自动匹配测试</button>
<button class="danmu-test-tab" onclick="switchDanmuTestTab('manual', event)">手动匹配测试</button>
<button class="danmu-test-tab" onclick="switchDanmuTestTab('favorite', event)">收藏</button>
</div>
<div class="danmu-test-panel active" id="auto-match-panel">
<p style="color: #666; margin-bottom: 15px;">模拟播放器自动匹配流程:输入文件名 → 匹配剧集 → 获取弹幕</p>
<div class="form-group" style="margin-bottom: 15px;">
<label>文件名</label>
<div style="display:flex;gap:10px;margin-top:5px;">
<div style="display:flex;gap:10px;margin-top:5px;flex-wrap:wrap;">
<input type="text" id="auto-match-filename" placeholder="示例: 生万物 S02E08, 无忧渡.S02E08.2160p.WEB-DL" style="flex:1;">
<button class="btn btn-success" id="auto-match-btn" onclick="autoMatchTest()">开始匹配</button>
</div>
@@ -196,12 +197,62 @@ export const HTML_TEMPLATE = /* html */ `
<div style="display:flex;gap:10px;margin-top:5px;">
<input type="text" id="manual-search-keyword" placeholder="请输入动漫名称" style="flex:1;">
<button class="btn btn-primary" id="manual-search-btn" onclick="manualSearchAnime()">搜索</button>
<button class="btn btn-success favorite-action-btn" id="manual-favorite-btn" onclick="favoriteManualSearch()" disabled>收藏</button>
</div>
</div>
<div id="manual-anime-list" style="display:none;"></div>
<div id="manual-episode-list" style="display:none;"></div>
</div>
<div class="danmu-test-panel" id="favorite-panel">
<p style="color: #666; margin-bottom: 15px;">收藏后的剧集会永久缓存,后续匹配可秒级返回缓存结果;对于《火影忍者》《名侦探柯南》等集数较多的剧集尤其有用,无需每次重新搜索。可在“手动匹配测试”界面搜索剧集后,点击“收藏”按钮添加搜索结果收藏。只缓存剧集搜索结果,不缓存弹幕。</p>
<div class="form-group" style="margin-bottom: 15px;">
<label>搜索收藏</label>
<div style="display:flex;gap:10px;margin-top:5px;">
<input type="text" id="favorite-search-input" placeholder="搜索收藏剧名或来源" oninput="handleFavoriteSearch(event)" style="flex:1;">
<button class="btn btn-primary" onclick="loadFavoriteList()">刷新列表</button>
</div>
</div>
<div class="preview-status" id="favorite-list-status" aria-live="polite"></div>
<div class="favorite-list" id="favorite-list"></div>
</div>
<div class="modal" id="favorite-schedule-modal">
<div class="modal-content favorite-schedule-modal-content">
<div class="modal-header">
<h3>设置定时刷新</h3>
<button class="close-btn" onclick="closeFavoriteScheduleModal()">&times;</button>
</div>
<div class="modal-body">
<p class="favorite-schedule-hint">按北京时间执行,仅 Node/Docker 部署支持定时刷新。</p>
<input type="hidden" id="favorite-schedule-keyword">
<div class="form-group">
<label for="favorite-schedule-frequency">刷新频率</label>
<select id="favorite-schedule-frequency" onchange="toggleFavoriteScheduleWeekday()">
<option value="daily">每天</option>
<option value="weekly">每周</option>
</select>
</div>
<div class="form-group" id="favorite-schedule-weekday-group" style="display:none;">
<label for="favorite-schedule-weekday">星期</label>
<select id="favorite-schedule-weekday">
<option value="1">周一</option><option value="2">周二</option><option value="3">周三</option>
<option value="4">周四</option><option value="5">周五</option><option value="6">周六</option><option value="7">周日</option>
</select>
</div>
<div class="form-group">
<label for="favorite-schedule-time">时间</label>
<input type="time" id="favorite-schedule-time" value="03:00">
</div>
</div>
<div class="modal-footer">
<button class="btn btn-danger" id="favorite-schedule-disable-btn" onclick="disableFavoriteSchedule()">关闭定时刷新</button>
<button class="btn btn-primary" onclick="closeFavoriteScheduleModal()">取消</button>
<button class="btn btn-success" onclick="saveFavoriteSchedule()">保存</button>
</div>
</div>
</div>
<div id="danmu-result-area" style="display:none;"></div>
</div>
</div>
@@ -345,9 +396,12 @@ export const HTML_TEMPLATE = /* html */ `
<div class="theme-settings" id="theme-settings" hidden>
<div class="theme-settings-copy">
<h3>界面主题</h3>
<span class="theme-current-label" id="theme-current-label">UI_THEME · 新叶绿</span>
<span class="theme-current-label" id="theme-current-label">UI_THEME · 经典默认</span>
</div>
<div class="theme-options" role="radiogroup" aria-label="界面主题选择">
<button type="button" role="radio" class="theme-option" data-theme-option="lavender" aria-checked="false" onclick="selectTheme('lavender')" title="经典默认">
<span class="theme-swatches" aria-hidden="true"><i style="background:#667eea"></i><i style="background:#5a6fd6"></i><i style="background:#eef0f8"></i></span><span class="theme-option-label">经典默认</span>
</button>
<button type="button" role="radio" class="theme-option" data-theme-option="shinyo" aria-checked="false" onclick="selectTheme('shinyo')" title="新叶绿">
<span class="theme-swatches" aria-hidden="true"><i style="background:#8cb48c"></i><i style="background:#7aa37a"></i><i style="background:#e8efe8"></i></span><span class="theme-option-label">新叶绿</span>
</button>
@@ -369,9 +423,6 @@ export const HTML_TEMPLATE = /* html */ `
<button type="button" role="radio" class="theme-option" data-theme-option="amber" aria-checked="false" onclick="selectTheme('amber')" title="LCL橘">
<span class="theme-swatches" aria-hidden="true"><i style="background:#f78c50"></i><i style="background:#e67a40"></i><i style="background:#f0ebe6"></i></span><span class="theme-option-label">LCL橘</span>
</button>
<button type="button" role="radio" class="theme-option" data-theme-option="lavender" aria-checked="false" onclick="selectTheme('lavender')" title="经典默认">
<span class="theme-swatches" aria-hidden="true"><i style="background:#667eea"></i><i style="background:#5a6fd6"></i><i style="background:#eef0f8"></i></span><span class="theme-option-label">经典默认</span>
</button>
</div>
</div>
+59 -7
View File
@@ -2,6 +2,7 @@ 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 { loadFavorites, resolveFavoriteForKeyword, saveFavorites } from "./favorite-util.js";
let fs, path;
// =====================
@@ -111,6 +112,9 @@ function sweepExpiredCache(cacheMap, cacheMinutes, cacheName) {
let sweptCount = 0;
for (const [key, entry] of cacheMap.entries()) {
if (cacheMap === globals.searchCache && globals.favoriteCache instanceof Map && globals.favoriteCache.has(key)) {
continue;
}
const ageMinutes = (now - entry.timestamp) / (1000 * 60);
if (ageMinutes > cacheMinutes) {
cacheMap.delete(key);
@@ -166,6 +170,26 @@ function* iterateSearchCacheDetails() {
yield anime;
}
}
// 收藏剧集的详情也纳入解析范围(永久缓存)
if (globals.favoriteCache instanceof Map) {
for (const cached of globals.favoriteCache.values()) {
if (!Array.isArray(cached.details)) {
continue;
}
for (const anime of cached.details) {
const identityKey = getAnimeIdentityKey(anime);
if (identityKey && seen.has(identityKey)) {
continue;
}
if (identityKey) {
seen.add(identityKey);
}
yield anime;
}
}
}
}
export function resolveAnimeById(id, detailStore = null, source = null) {
@@ -259,6 +283,10 @@ export function resolveEpisodeContextById(id, detailStore = null) {
}
// 检查搜索缓存是否有效(未过期)
export function isSearchCacheValid(keyword) {
if (resolveFavoriteForKeyword(keyword)) {
return true;
}
if (!globals.searchCache.has(keyword)) {
return false;
}
@@ -279,6 +307,19 @@ export function isSearchCacheValid(keyword) {
// 获取搜索缓存
export function getSearchCache(keyword, detailsMap = null) {
// 收藏剧集永久缓存优先命中(无 TTL、无数量上限)
const favorite = resolveFavoriteForKeyword(keyword);
if (favorite) {
const favoriteEntry = favorite.entry;
log("info", `[cache] Using favorite cache for "${keyword}"`);
if (detailsMap instanceof Map && Array.isArray(favoriteEntry.details)) {
favoriteEntry.details.forEach(anime => {
storeAnimeDetail(detailsMap, anime);
});
}
return favoriteEntry.results;
}
if (isSearchCacheValid(keyword)) {
log("info", `[cache] Using search cache for "${keyword}"`);
const cached = globals.searchCache.get(keyword);
@@ -719,7 +760,6 @@ export async function getLocalCaches() {
if (!globals.localCacheInitialized) {
try {
log("info", '[cache] getLocalCaches start.');
// 从本地缓存文件读取数据并恢复到 globals 中
globals.animes = JSON.parse(readCacheFromFile('animes')) || globals.animes;
globals.episodeIds = JSON.parse(readCacheFromFile('episodeIds')) || globals.episodeIds;
@@ -727,6 +767,11 @@ export async function getLocalCaches() {
globals.reqRecords = JSON.parse(readCacheFromFile('reqRecords')) || globals.reqRecords;
globals.todayReqNum = JSON.parse(readCacheFromFile('todayReqNum')) || globals.todayReqNum;
const favoriteCacheData = readCacheFromFile('favoritesCache');
if (favoriteCacheData) {
loadFavorites(typeof favoriteCacheData === 'string' ? JSON.parse(favoriteCacheData) : favoriteCacheData);
}
// 恢复 lastSelectMap 并转换为 Map 对象
const lastSelectMapData = readCacheFromFile('lastSelectMap');
if (lastSelectMapData) {
@@ -741,6 +786,7 @@ export async function getLocalCaches() {
globals.lastHashes.reqRecords = simpleHash(JSON.stringify(globals.reqRecords));
globals.lastHashes.todayReqNum = simpleHash(JSON.stringify(globals.todayReqNum));
globals.lastHashes.lastSelectMap = simpleHash(JSON.stringify(Object.fromEntries(globals.lastSelectMap)));
globals.lastHashes.favoriteCache = simpleHash(JSON.stringify(saveFavorites()));
globals.localCacheInitialized = true;
log("info", '[cache] getLocalCaches completed successfully.');
@@ -764,24 +810,30 @@ export async function updateLocalCaches() {
{ key: 'episodeNum', value: globals.episodeNum },
{ key: 'reqRecords', value: globals.reqRecords },
{ key: 'lastSelectMap', value: globals.lastSelectMap },
{ key: 'todayReqNum', value: globals.todayReqNum }
{ key: 'todayReqNum', value: globals.todayReqNum },
{ key: 'favoritesCache', value: globals.favoriteCache }
];
for (const { key, value } of variables) {
// 对于 lastSelectMap(Map 对象),需要转换为普通对象后再序列化
const serializedValue = key === 'lastSelectMap' ? JSON.stringify(Object.fromEntries(value)) : JSON.stringify(value);
const serializedValue = key === 'lastSelectMap'
? JSON.stringify(Object.fromEntries(value))
: key === 'favoritesCache'
? JSON.stringify(saveFavorites())
: JSON.stringify(value);
const currentHash = simpleHash(serializedValue);
if (currentHash !== globals.lastHashes[key]) {
const hashKey = key === 'favoritesCache' ? 'favoriteCache' : key;
if (currentHash !== globals.lastHashes[hashKey]) {
writeCacheToFile(key, serializedValue);
updates.push({ key, hash: currentHash });
updates.push({ key, hashKey, hash: currentHash });
}
}
// 输出更新日志
if (updates.length > 0) {
log("info", `[cache] Updated local caches for keys: ${updates.map(u => u.key).join(', ')}`);
updates.forEach(({ key, hash }) => {
globals.lastHashes[key] = hash; // 更新本地哈希
updates.forEach(({ hashKey, hash }) => {
globals.lastHashes[hashKey] = hash; // 更新本地哈希
});
} else {
log("info", '[cache] No changes detected, skipping local cache update.');
+2 -2
View File
@@ -18,8 +18,8 @@ export function simpleHash(str) {
// 辅助函数:序列化值,处理 Map 对象
export function serializeValue(key, value) {
// 对于 lastSelectMap(Map 对象),需要转换为普通对象后再序列化
if (key === 'lastSelectMap' && value instanceof Map) {
// Redis 中持久化的 Map 转成普通对象,避免 JSON.stringify(Map) 得到空对象。
if ((key === 'lastSelectMap' || key === 'favoriteCache') && value instanceof Map) {
return JSON.stringify(Object.fromEntries(value));
}
return JSON.stringify(value);
+181
View File
@@ -0,0 +1,181 @@
import { globals } from '../configs/globals.js';
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
let schedulerTimer = null;
let schedulerRunning = false;
function parseTime(time) {
const match = /^(\d{2}):(\d{2})$/.exec(String(time || ''));
if (!match) return null;
const hour = Number(match[1]);
const minute = Number(match[2]);
if (hour > 23 || minute > 59) return null;
return { hour, minute, value: `${match[1]}:${match[2]}` };
}
function shanghaiDateParts(timestamp) {
const shifted = new Date(timestamp + SHANGHAI_OFFSET_MS);
const sundayBasedDay = shifted.getUTCDay();
return {
year: shifted.getUTCFullYear(),
month: shifted.getUTCMonth(),
date: shifted.getUTCDate(),
weekday: sundayBasedDay === 0 ? 7 : sundayBasedDay
};
}
function shanghaiTimestamp(year, month, date, hour, minute) {
return Date.UTC(year, month, date, hour, minute) - SHANGHAI_OFFSET_MS;
}
export function validateFavoriteSchedule(value) {
if (!value || typeof value !== 'object') {
return { valid: false, message: '缺少定时刷新配置' };
}
const frequency = String(value.frequency || '').toLowerCase();
if (frequency !== 'daily' && frequency !== 'weekly') {
return { valid: false, message: 'frequency 仅支持 daily 或 weekly' };
}
const parsedTime = parseTime(value.time);
if (!parsedTime) {
return { valid: false, message: 'time 必须是 HH:mm 格式的有效时间' };
}
const weekday = Number(value.weekday);
if (frequency === 'weekly' && (!Number.isInteger(weekday) || weekday < 1 || weekday > 7)) {
return { valid: false, message: 'weekly 模式的 weekday 必须是 1-7' };
}
return {
valid: true,
value: {
frequency,
time: parsedTime.value,
...(frequency === 'weekly' ? { weekday } : {}),
timezone: 'Asia/Shanghai'
}
};
}
export function calculateNextFavoriteRunAt(schedule, fromTimestamp = Date.now()) {
const validated = validateFavoriteSchedule(schedule);
if (!validated.valid) return 0;
const parsedTime = parseTime(validated.value.time);
const parts = shanghaiDateParts(fromTimestamp);
let candidate = shanghaiTimestamp(parts.year, parts.month, parts.date, parsedTime.hour, parsedTime.minute);
if (validated.value.frequency === 'daily') {
if (candidate <= fromTimestamp) candidate += DAY_MS;
return candidate;
}
let daysAhead = (validated.value.weekday - parts.weekday + 7) % 7;
candidate += daysAhead * DAY_MS;
if (candidate <= fromTimestamp) candidate += 7 * DAY_MS;
return candidate;
}
export function createFavoriteSchedule(value, now = Date.now()) {
const validated = validateFavoriteSchedule(value);
if (!validated.valid) return validated;
return {
valid: true,
value: {
...validated.value,
nextRunAt: calculateNextFavoriteRunAt(validated.value, now),
retryAt: null,
lastRunAt: null,
lastStatus: '',
lastError: ''
}
};
}
export function normalizeFavoriteSchedule(value, now = Date.now()) {
const created = createFavoriteSchedule(value, now);
if (!created.valid) return null;
const numberOrNull = candidate => {
const number = Number(candidate);
return Number.isFinite(number) && number > 0 ? number : null;
};
return {
...created.value,
nextRunAt: numberOrNull(value.nextRunAt) || created.value.nextRunAt,
retryAt: numberOrNull(value.retryAt),
lastRunAt: numberOrNull(value.lastRunAt),
lastStatus: value.lastStatus === 'success' || value.lastStatus === 'failed' ? value.lastStatus : '',
lastError: typeof value.lastError === 'string' ? value.lastError : ''
};
}
export async function runDueFavoriteSchedules({
favoriteCache,
now = Date.now(),
refresh,
persist
}) {
if (!(favoriteCache instanceof Map) || typeof refresh !== 'function') return [];
const results = [];
let changed = false;
for (const [keyword, entry] of favoriteCache.entries()) {
const schedule = normalizeFavoriteSchedule(entry?.refreshSchedule, now);
if (!schedule) continue;
entry.refreshSchedule = schedule;
const isRetry = schedule.retryAt !== null && schedule.retryAt <= now;
const isNormalRun = schedule.nextRunAt <= now;
if (!isRetry && !isNormalRun) continue;
// 无论停机多久,每次检查只执行一次,并从当前时间计算下个正常周期。
if (isNormalRun) schedule.nextRunAt = calculateNextFavoriteRunAt(schedule, now);
schedule.lastRunAt = now;
changed = true;
try {
await refresh(keyword);
const currentEntry = favoriteCache.get(keyword);
if (currentEntry) currentEntry.refreshSchedule = schedule;
schedule.retryAt = null;
schedule.lastStatus = 'success';
schedule.lastError = '';
results.push({ keyword, success: true, retry: isRetry });
} catch (error) {
schedule.lastStatus = 'failed';
schedule.lastError = error?.message || String(error);
schedule.retryAt = isRetry ? null : now + 10 * 60 * 1000;
results.push({ keyword, success: false, retry: isRetry, error: schedule.lastError });
}
}
if (changed && typeof persist === 'function') await persist();
return results;
}
export async function startFavoriteScheduler({ refresh, persist, intervalMs = 60 * 1000 }) {
if (schedulerTimer || typeof refresh !== 'function') return false;
const tick = async () => {
if (schedulerRunning) return;
schedulerRunning = true;
try {
await runDueFavoriteSchedules({
favoriteCache: globals.favoriteCache,
refresh,
persist
});
} finally {
schedulerRunning = false;
}
};
await tick();
schedulerTimer = setInterval(tick, intervalMs);
if (typeof schedulerTimer.unref === 'function') schedulerTimer.unref();
return true;
}
export function stopFavoriteScheduler() {
if (schedulerTimer) clearInterval(schedulerTimer);
schedulerTimer = null;
schedulerRunning = false;
}
+163
View File
@@ -0,0 +1,163 @@
import { globals } from '../configs/globals.js';
import { log } from './log-util.js';
import { normalizeFavoriteSchedule } from './favorite-schedule-util.js';
function normalizeEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const timestamp = Number(entry.timestamp) || Date.now();
return {
results: Array.isArray(entry.results) ? entry.results : [],
details: Array.isArray(entry.details) ? entry.details : [],
timestamp,
// 旧缓存只有 timestamp;将其作为最近一次生成收藏快照的时间兼容恢复。
lastRefreshAt: Number(entry.lastRefreshAt) || timestamp,
refreshSchedule: normalizeFavoriteSchedule(entry.refreshSchedule)
};
}
function toFavoriteMap(value) {
if (value instanceof Map) return new Map(value);
let parsed = value;
if (typeof parsed === 'string') {
try {
parsed = JSON.parse(parsed);
} catch {
parsed = {};
}
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return new Map();
}
const entries = Object.entries(parsed)
.map(([keyword, entry]) => [String(keyword).trim(), normalizeEntry(entry)])
.filter(([keyword, entry]) => keyword && entry);
return new Map(entries);
}
// 从持久化快照恢复收藏。serverless 环境可不调用,继续使用实例内存。
export function loadFavorites(value = {}) {
globals.favoriteCache = toFavoriteMap(value);
log('info', `[favorite] Restored ${globals.favoriteCache.size} favorite entries`);
return globals.favoriteCache;
}
// 返回可由 cache-util 持久化的普通对象快照。
export function saveFavorites() {
if (!(globals.favoriteCache instanceof Map)) globals.favoriteCache = new Map();
return Object.fromEntries(globals.favoriteCache.entries());
}
// 去掉关键词末尾的季节后缀 _S{n},得到用于兜底匹配的剧名。
export function stripSeasonSuffix(keyword) {
return String(keyword || '').replace(/_S\d+$/i, '').trim();
}
function getEntryTitles(keyword, entry) {
const titles = new Set([stripSeasonSuffix(keyword)]);
for (const anime of Array.isArray(entry?.results) ? entry.results : []) {
const title = stripSeasonSuffix(anime?.animeTitle);
if (title) titles.add(title);
}
return [...titles].filter(title => title.length >= 2);
}
// 先精确匹配关键词,再按收藏键和收藏结果中的剧名做包含兜底。
export function resolveFavoriteForKeyword(keyword) {
const key = String(keyword || '').trim();
if (!key || !(globals.favoriteCache instanceof Map) || globals.favoriteCache.size === 0) return null;
if (globals.favoriteCache.has(key)) {
return { keyword: key, entry: globals.favoriteCache.get(key) };
}
const queryTitle = stripSeasonSuffix(key);
if (queryTitle.length < 2) return null;
for (const [favoriteKeyword, entry] of globals.favoriteCache.entries()) {
const matched = getEntryTitles(favoriteKeyword, entry).some(title =>
queryTitle.includes(title) || title.includes(queryTitle)
);
if (matched) return { keyword: favoriteKeyword, entry };
}
return null;
}
export function addFavorite(keyword, results, details = []) {
const key = String(keyword || '').trim();
if (!key) return null;
if (!(globals.favoriteCache instanceof Map)) globals.favoriteCache = new Map();
const now = Date.now();
const entry = normalizeEntry({ results, details, timestamp: now, lastRefreshAt: now });
globals.favoriteCache.set(key, entry);
log('info', `[favorite] Added favorite "${key}" with ${entry.results.length} results`);
return entry;
}
export function removeFavorite(keyword) {
const resolved = resolveFavoriteForKeyword(keyword);
if (!resolved) return false;
globals.favoriteCache.delete(resolved.keyword);
log('info', `[favorite] Removed favorite "${resolved.keyword}"`);
return true;
}
// 用强制搜索得到的新快照覆盖收藏,保留原收藏键以避免刷新时产生重复项。
export function refreshFavorite(keyword, results, details = []) {
const resolved = resolveFavoriteForKeyword(keyword);
const key = resolved?.keyword || stripSeasonSuffix(keyword);
if (!key) return null;
const now = Date.now();
const entry = normalizeEntry({
results,
details,
timestamp: Number(resolved?.entry?.timestamp) || now,
lastRefreshAt: now,
refreshSchedule: resolved?.entry?.refreshSchedule || null
});
if (!(globals.favoriteCache instanceof Map)) globals.favoriteCache = new Map();
globals.favoriteCache.set(key, entry);
log('info', `[favorite] Refreshed favorite "${key}" with ${entry.results.length} results`);
return entry;
}
export function listFavorites() {
if (!(globals.favoriteCache instanceof Map)) return [];
const items = [];
for (const [keyword, entry] of globals.favoriteCache.entries()) {
const results = Array.isArray(entry?.results) ? entry.results : [];
const primary = results[0] || {};
const sources = [...new Set(results.map(anime => anime?.source).filter(Boolean))];
const episodeCount = results.reduce((sum, anime) => sum + (Number(anime?.episodeCount) || 0), 0);
items.push({
keyword,
animeTitle: keyword,
source: sources.join('、'),
sources,
imageUrl: primary.imageUrl || '',
episodeCount,
resultsCount: results.length,
timestamp: Number(entry?.timestamp) || 0,
lastRefreshAt: Number(entry?.lastRefreshAt) || Number(entry?.timestamp) || 0,
refreshSchedule: normalizeFavoriteSchedule(entry?.refreshSchedule)
});
}
return items.sort((a, b) => b.lastRefreshAt - a.lastRefreshAt);
}
// 兼容内部旧调用名称。
export function getFavoriteEntry(keyword) {
return globals.favoriteCache instanceof Map ? globals.favoriteCache.get(String(keyword || '').trim()) || null : null;
}
export function getFavoriteEntryForKeyword(keyword) {
return resolveFavoriteForKeyword(keyword)?.entry || null;
}
+28 -5
View File
@@ -1,6 +1,7 @@
import { globals } from '../configs/globals.js';
import { log } from './log-util.js'
import { simpleHash, serializeValue } from "./codec-util.js";
import { loadFavorites } from './favorite-util.js';
// =====================
// upstash redis 读写请求 (先简单实现,不加锁)
@@ -152,7 +153,9 @@ export async function getRedisCaches() {
if (!globals.redisCacheInitialized) {
try {
log("info", '[system] [redis] getRedisCaches start.');
const keys = ['animes', 'episodeIds', 'episodeNum', 'reqRecords', 'lastSelectMap', 'todayReqNum'];
const keys = [
'animes', 'episodeIds', 'episodeNum', 'reqRecords', 'lastSelectMap', 'todayReqNum', 'favoriteCache'
];
const commands = keys.map(key => ['GET', key]); // 构造 pipeline 命令
const results = await runPipeline(commands);
@@ -169,6 +172,7 @@ export async function getRedisCaches() {
log("info", `[system] [redis] Restored lastSelectMap from Redis with ${globals.lastSelectMap.size} entries`);
}
globals.todayReqNum = results[5].result ? parseInt(results[5].result, 10) : globals.todayReqNum;
if (results[6]?.result) loadFavorites(results[6].result);
// 更新哈希值
globals.lastHashes.animes = simpleHash(JSON.stringify(globals.animes));
@@ -177,6 +181,7 @@ export async function getRedisCaches() {
globals.lastHashes.reqRecords = simpleHash(JSON.stringify(globals.reqRecords));
globals.lastHashes.lastSelectMap = simpleHash(JSON.stringify(Object.fromEntries(globals.lastSelectMap)));
globals.lastHashes.todayReqNum = simpleHash(JSON.stringify(globals.todayReqNum));
globals.lastHashes.favoriteCache = simpleHash(serializeValue('favoriteCache', globals.favoriteCache));
globals.redisCacheInitialized = true;
log("info", '[system] [redis] getRedisCaches completed successfully.');
@@ -187,6 +192,24 @@ export async function getRedisCaches() {
}
}
// serverless 多实例场景下单独刷新收藏缓存。
// Redis 中的收藏是跨实例的持久数据,但实例内存中的 favoriteCache 只在首次初始化时加载,
// 预热实例可能错过其他实例新增的收藏,这里在收藏相关请求时直接从 Redis 重新读取。
export async function getFavoriteCachesFromRedis() {
if (!globals.redisValid) return false;
try {
const results = await runPipeline([['GET', 'favoriteCache']]);
if (results?.[0]?.result) {
loadFavorites(results[0].result);
globals.lastHashes.favoriteCache = simpleHash(serializeValue('favoriteCache', globals.favoriteCache));
}
return true;
} catch (error) {
log("error", `[system] [redis] getFavoriteCachesFromRedis failed: ${error.message}`);
return false;
}
}
// 优化后的 updateRedisCaches,仅更新有变化的变量
export async function updateRedisCaches() {
try {
@@ -201,12 +224,12 @@ export async function updateRedisCaches() {
{ key: 'episodeNum', value: globals.episodeNum },
{ key: 'reqRecords', value: globals.reqRecords },
{ key: 'lastSelectMap', value: globals.lastSelectMap },
{ key: 'todayReqNum', value: globals.todayReqNum }
{ key: 'todayReqNum', value: globals.todayReqNum },
{ key: 'favoriteCache', value: globals.favoriteCache }
];
for (const { key, value } of variables) {
// 对于 lastSelectMap(Map 对象),需要转换为普通对象后再序列化
const serializedValue = key === 'lastSelectMap' ? JSON.stringify(Object.fromEntries(value)) : JSON.stringify(value);
const serializedValue = serializeValue(key, value);
const currentHash = simpleHash(serializedValue);
if (currentHash !== globals.lastHashes[key]) {
commands.push(['SET', key, serializedValue]);
@@ -260,4 +283,4 @@ export async function judgeRedisValid(path) {
globals.redisValid = true;
}
}
}
}
+35 -2
View File
@@ -1,12 +1,13 @@
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 { getFavoriteCachesFromRedis, getRedisCaches, judgeRedisValid } from "./utils/redis-util.js";
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";
@@ -77,7 +78,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
// --- 校验 token ---
const parts = path.split("/").filter(Boolean); // 去掉空段
const knownApiPaths = ["api", "v1", "v2", "search", "match", "bangumi", "comment", "danmaku"];
const knownApiPaths = ["api", "v1", "v2", "search", "match", "favorite", "bangumi", "comment", "danmaku"];
const firstPart = parts[0] || "";
const isDefaultToken = globals.token === "87654321";
@@ -95,6 +96,10 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
if (globals.redisValid && path !== "/favicon.ico" && path !== "/robots.txt") {
await getRedisCaches();
}
// serverless 多实例下,收藏请求每次都从 Redis 刷新收藏缓存,避免读到预热实例的过期空快照
if (globals.redisValid && deployPlatform !== "node" && path.includes("/favorite")) {
await getFavoriteCachesFromRedis();
}
if (deployPlatform === "node" && globals.localRedisValid && path !== "/favicon.ico" && path !== "/robots.txt") {
const { getLocalRedisCaches } = await import("./utils/local-redis-util.js");
await getLocalRedisCaches();
@@ -104,6 +109,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
const targetPaths = [
'/api/v2/search/anime',
'/api/v2/match',
'/api/v2/favorite',
'/api/v2/search/episodes',
'/api/v2/fongmi/danmaku',
'/danmaku',
@@ -258,6 +264,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
if (path !== "/" && path !== "/danmaku" && path !== "/api/logs" && !path.startsWith('/api/env')
&& !path.startsWith('/api/deploy') && !path.startsWith('/api/cache')
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
&& !path.startsWith('/api/favorite')
&& !path.startsWith('/api/ai') && !path.startsWith('/api/debug')) {
log("info", `[system] [path check] Starting path normalization for: "${path}"`);
const pathBeforeCleanup = path; // 保存清理前的路径检查是否修改
@@ -282,6 +289,7 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
if (!path.startsWith('/api/v2') && path !== '/' && !path.startsWith('/api/logs')
&& !path.startsWith('/api/env') && !path.startsWith('/api/cache')
&& !path.startsWith('/api/cookie') && !path.startsWith('/api/config')
&& !path.startsWith('/api/favorite')
&& !path.startsWith('/api/ai') && !path.startsWith('/api/debug')) {
if (path.startsWith('/v2/') || path === '/v2') {
log("info", `[system] [path check] Path is missing /api prefix. Adding /api...`);
@@ -332,6 +340,31 @@ async function handleRequest(req, env, deployPlatform, clientIp, ctx) {
if (path === "/api/v2/match" && method === "POST") {
return matchAnime(url, req, clientIp);
}
// POST /api/v2/favorite/add - 收藏剧集(永久缓存)
if ((path === "/api/v2/favorite/add" || path === "/api/favorite/add") && method === "POST") {
return handleFavoriteAdd(req, url);
}
// POST /api/v2/favorite/refresh - 刷新收藏缓存
if ((path === "/api/v2/favorite/refresh" || path === "/api/favorite/refresh") && method === "POST") {
return handleFavoriteRefresh(req, url);
}
// POST /api/v2/favorite/schedule - 设置或关闭定时刷新
if ((path === "/api/v2/favorite/schedule" || path === "/api/favorite/schedule") && method === "POST") {
return handleFavoriteSchedule(req);
}
// POST /api/v2/favorite/remove - 删除收藏
if ((path === "/api/v2/favorite/remove" || path === "/api/favorite/remove") && method === "POST") {
return handleFavoriteRemove(req);
}
// GET /api/v2/favorite/list - 收藏列表
if ((path === "/api/v2/favorite/list" || path === "/api/favorite/list") && method === "GET") {
return handleFavoriteList();
}
// GET /api/v2/bangumi/:animeId
if (path.startsWith("/api/v2/bangumi/") && method === "GET") {
+314 -2
View File
@@ -6,7 +6,8 @@ import test from 'node:test';
import assert from 'node:assert';
import { handleRequest } from './worker.js';
import { extractTitleSeasonEpisode, getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, buildSearchAnimeUrl } from "./apis/dandan-api.js";
import { getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry } from "./utils/redis-util.js";
import { handleFavoriteRefresh } from './apis/favorite-api.js';
import { getRedisCaches, getRedisKey, pingRedis, setRedisKey, setRedisKeyWithExpiry, updateRedisCaches } from "./utils/redis-util.js";
import { getLocalRedisKey, setLocalRedisKey, setLocalRedisKeyWithExpiry } from "./utils/local-redis-util.js";
import { getImdbepisodes } from "./utils/imdb-util.js";
import { getTMDBChineseTitle, getTmdbJpDetail, searchTmdbTitles } from "./utils/tmdb-util.js";
@@ -37,7 +38,11 @@ import { EdgeoneHandler } from "./configs/handlers/edgeone-handler.js";
import { HuggingfaceHandler } from "./configs/handlers/huggingface-handler.js";
import { HandlerFactory } from "./configs/handlers/handler-factory.js";
import { Globals } from "./configs/globals.js";
import { addAnime, addEpisode } from "./utils/cache-util.js";
import { addAnime, addEpisode, getSearchCache, isSearchCacheValid, setSearchCache } from "./utils/cache-util.js";
import { addFavorite, listFavorites, loadFavorites, removeFavorite, resolveFavoriteForKeyword, saveFavorites } from './utils/favorite-util.js';
import { HTML_TEMPLATE } from './ui/template.js';
import { apitestJsContent } from './ui/js/apitest.js';
import { systemSettingsJsContent } from './ui/js/systemsettings.js';
import { convertToAsciiSum } from "./utils/codec-util.js";
import { convertToDanmakuJson, handleDanmusLike } from "./utils/danmu-util.js";
import { Segment, SegmentListResponse } from "./models/dandan-model.js"
@@ -112,6 +117,45 @@ function resetSearchState() {
delete Globals.requestAnimeDetailsMap;
}
function resetFavoriteState(env = {}) {
Globals.init(env);
Globals.animes = [];
Globals.episodeIds = [];
Globals.episodeNum = 10001;
Globals.searchCache = new Map();
Globals.commentCache = new Map();
Globals.favoriteCache = new Map();
Globals.requestHistory = new Map();
Globals.localCacheValid = false;
Globals.localCacheInitialized = false;
}
function createFavoriteAnime(title = '收藏测试', episodeCount = 2, id = 910001) {
return {
animeId: id,
bangumiId: String(id),
animeTitle: title,
type: 'tvseries',
typeDescription: 'TV',
imageUrl: 'https://example.com/favorite.jpg',
startDate: '2026-01-01T00:00:00.000Z',
episodeCount,
rating: 0,
isFavorited: true,
source: 'tencent',
links: Array.from({ length: episodeCount }, (_, index) => ({
id: id * 10 + index + 1,
url: `https://v.qq.com/x/cover/favorite/ep${index + 1}.html`,
title: `【qq】 第${index + 1}集`
}))
};
}
function favoriteSearchResult(anime) {
const { links, ...result } = anime;
return result;
}
const urlPrefix = "http://localhost:9321";
const token = "87654321";
@@ -295,6 +339,274 @@ test('worker.js API endpoints', async (t) => {
resetSearchState();
});
await t.test('Upstash Redis persists favorites without storing search or comment caches', async () => {
resetFavoriteState({
UPSTASH_REDIS_REST_URL: 'https://redis.example.com',
UPSTASH_REDIS_REST_TOKEN: 'test-token',
LOG_LEVEL: 'error'
});
Globals.redisValid = true;
Globals.redisCacheInitialized = false;
Globals.lastHashes = {
animes: null,
episodeIds: null,
episodeNum: null,
lastSelectMap: null,
reqRecords: null,
todayReqNum: null,
favoriteCache: null
};
const anime = createFavoriteAnime('Redis cache test');
Globals.searchCache.set('Redis search', {
results: [favoriteSearchResult(anime)],
details: [anime],
timestamp: Date.now()
});
Globals.commentCache.set('https://example.com/video', {
comments: [{ p: '1,1,16777215,test', m: 'cached' }],
timestamp: Date.now()
});
addFavorite('Redis favorite', [favoriteSearchResult(anime)], [anime]);
Globals.favoriteCache.get('Redis favorite').timestamp = Date.now() - 24 * 60 * 60 * 1000;
const redisData = new Map();
const redisCommands = [];
await withMockFetch(async (_url, options) => {
const commands = JSON.parse(options.body);
redisCommands.push(...commands);
return {
json: async () => commands.map(command => {
if (command[0] === 'SET') {
redisData.set(command[1], command[2]);
return { result: 'OK' };
}
return { result: redisData.get(command[1]) ?? null };
})
};
}, async () => {
await updateRedisCaches();
assert.ok(redisData.has('favoriteCache'));
assert.equal(redisData.has('searchCache'), false);
assert.equal(redisData.has('commentCache'), false);
Globals.searchCache = new Map();
Globals.commentCache = new Map();
Globals.favoriteCache = new Map();
Globals.redisCacheInitialized = false;
await getRedisCaches();
});
assert.equal(Globals.searchCache.size, 0);
assert.equal(Globals.commentCache.size, 0);
assert.equal(redisCommands.some(command => command[1] === 'searchCache'), false);
assert.equal(redisCommands.some(command => command[1] === 'commentCache'), false);
assert.equal(resolveFavoriteForKeyword('Redis favorite')?.entry.results[0].animeId, anime.animeId);
assert.equal(getSearchCache('Redis favorite')[0].animeId, anime.animeId);
Globals.redisValid = false;
});
await t.test('favorite cache', async t => {
await t.test('add/list/remove and serialization round trip', () => {
resetFavoriteState();
const anime = createFavoriteAnime();
const entry = addFavorite('收藏测试_S1', [favoriteSearchResult(anime)], [anime]);
assert.equal(entry.results.length, 1);
assert.equal(Globals.favoriteCache.size, 1);
assert.equal(listFavorites()[0].episodeCount, 2);
assert.equal(listFavorites()[0].lastRefreshAt, entry.lastRefreshAt);
assert.equal(resolveFavoriteForKeyword('收藏测试剧场版')?.entry, entry);
const snapshot = saveFavorites();
Globals.favoriteCache = new Map();
loadFavorites(JSON.stringify(snapshot));
assert.deepEqual(saveFavorites(), snapshot);
assert.equal(removeFavorite('收藏测试 第二季'), true);
assert.equal(Globals.favoriteCache.size, 0);
});
await t.test('favorite entries ignore search TTL, sweep, and count limits', () => {
resetFavoriteState({ SEARCH_CACHE_MINUTES: '1', LOG_LEVEL: 'error' });
const anime = createFavoriteAnime();
const oldTimestamp = Date.now() - 24 * 60 * 60 * 1000;
addFavorite('收藏测试', [favoriteSearchResult(anime)], [anime]);
Globals.favoriteCache.get('收藏测试').timestamp = oldTimestamp;
Globals.searchCache.set('收藏测试', { results: [], details: [], timestamp: oldTimestamp });
Globals.searchCache.set('普通过期缓存', { results: [], details: [], timestamp: oldTimestamp });
setSearchCache('新缓存', [], new Map());
assert.equal(isSearchCacheValid('收藏测试_S9'), true);
assert.equal(getSearchCache('收藏测试剧场版')[0].animeId, anime.animeId);
assert.equal(Globals.favoriteCache.has('收藏测试'), true);
assert.equal(Globals.searchCache.has('收藏测试'), true);
assert.equal(Globals.searchCache.has('普通过期缓存'), false);
for (let index = 0; index < 510; index++) addFavorite(`无限收藏${index}`, [], []);
assert.equal(Globals.favoriteCache.size, 511);
});
await t.test('search and match short-circuit external sources and expose isFavorite', async () => {
resetFavoriteState();
const anime = createFavoriteAnime();
addFavorite('收藏测试', [favoriteSearchResult(anime)], [anime]);
let fetchCount = 0;
const originalFetch = globalThis.fetch;
globalThis.fetch = async () => {
fetchCount++;
throw new Error('favorite hit must not use fetch');
};
try {
const searchResponse = await searchAnime(new URL('http://localhost/api/v2/search/anime?keyword=收藏测试&season=1&episode=2'));
const searchBody = await parseResponse(searchResponse);
assert.equal(searchBody.animes[0].animeId, anime.animeId);
const request = new Request('http://localhost/api/v2/match', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fileName: '收藏测试 S01E02' })
});
const matchBody = await parseResponse(await matchAnime(new URL(request.url), request, '127.0.0.1'));
assert.equal(matchBody.isMatched, true);
assert.equal(matchBody.matches[0].episodeId, anime.links[1].id);
assert.equal(matchBody.matches[0].isFavorite, true);
assert.equal(fetchCount, 0);
} finally {
globalThis.fetch = originalFetch;
}
});
await t.test('favorite API add/list/remove follows token path normalization', async () => {
resetFavoriteState();
const anime = createFavoriteAnime('路由收藏测试');
Globals.searchCache.set('路由收藏测试_S1', {
results: [favoriteSearchResult(anime)],
details: [anime],
timestamp: Date.now()
});
const addResponse = await handleRequest(new Request('http://localhost/api/v2/favorite/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fileName: '路由收藏测试 S01E01' })
}), {}, 'cloudflare', '127.0.0.1', {});
assert.equal(addResponse.status, 200);
assert.equal((await parseResponse(addResponse)).isFavorite, true);
const listResponse = await handleRequest(
new Request('http://localhost/87654321/api/favorite/list'),
{}, 'cloudflare', '127.0.0.1', {}
);
const listBody = await parseResponse(listResponse);
assert.equal(listBody.favorites.length, 1);
assert.equal(listBody.favorites[0].animeTitle, '路由收藏测试');
const removeResponse = await handleRequest(new Request('http://localhost/87654321/api/favorite/remove', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ keyword: listBody.favorites[0].keyword })
}), {}, 'cloudflare', '127.0.0.1', {});
assert.equal(removeResponse.status, 200);
assert.equal(Globals.favoriteCache.size, 0);
assert.equal(Globals.searchCache.has('路由收藏测试_S1'), false);
});
await t.test('manual favorite keeps the search keyword and uses the first result image', async () => {
resetFavoriteState();
const anime = createFavoriteAnime('火影忍者 疾风传', 720, 915001);
Globals.searchCache.set('火影忍者', {
results: [favoriteSearchResult(anime)],
details: [anime],
timestamp: Date.now()
});
const addResponse = await handleRequest(new Request('http://localhost/api/v2/favorite/add', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ keyword: '火影忍者' })
}), {}, 'cloudflare', '127.0.0.1', {});
const addBody = await parseResponse(addResponse);
assert.equal(addResponse.status, 200);
assert.equal(addBody.keyword, '火影忍者');
assert.equal(addBody.animeTitle, '火影忍者');
assert.equal(addBody.imageUrl, anime.imageUrl);
const listBody = await parseResponse(await handleRequest(
new Request('http://localhost/api/v2/favorite/list'),
{}, 'cloudflare', '127.0.0.1', {}
));
assert.equal(listBody.favorites[0].keyword, '火影忍者');
assert.equal(listBody.favorites[0].animeTitle, '火影忍者');
assert.equal(listBody.favorites[0].imageUrl, anime.imageUrl);
assert.equal(listBody.favorites[0].resultsCount, 1);
});
await t.test('refresh always performs a new source search and rebuilds the favorite', async () => {
resetFavoriteState();
const oldAnime = createFavoriteAnime('刷新测试', 1, 920001);
const refreshedAnime = createFavoriteAnime('刷新测试', 3, 920002);
const originalTimestamp = Date.now() - 60_000;
const favorite = addFavorite('刷新测试', [favoriteSearchResult(oldAnime)], [oldAnime]);
favorite.timestamp = originalTimestamp;
favorite.lastRefreshAt = originalTimestamp;
const originalSearch = TencentSource.prototype.search;
const originalHandleAnimes = TencentSource.prototype.handleAnimes;
const originalOrder = Globals.envs.sourceOrderArr;
let searchCount = 0;
TencentSource.prototype.search = async () => {
searchCount++;
return [{}];
};
TencentSource.prototype.handleAnimes = async (_source, _title, results, details) => {
results.push(refreshedAnime);
details.set(String(refreshedAnime.animeId), refreshedAnime);
};
Globals.envs.sourceOrderArr = ['tencent'];
try {
const refreshRequest = new Request('http://localhost/api/v2/favorite/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ keyword: '刷新测试' })
});
const refreshResponse = await handleFavoriteRefresh(refreshRequest, new URL(refreshRequest.url));
assert.equal(refreshResponse.status, 200);
assert.equal(searchCount, 1);
assert.equal(resolveFavoriteForKeyword('刷新测试').entry.results[0].animeId, refreshedAnime.animeId);
assert.equal(resolveFavoriteForKeyword('刷新测试').entry.details[0].links.length, 3);
assert.equal(resolveFavoriteForKeyword('刷新测试').entry.timestamp, originalTimestamp);
assert.ok(resolveFavoriteForKeyword('刷新测试').entry.lastRefreshAt > originalTimestamp);
assert.equal(listFavorites()[0].lastRefreshAt, resolveFavoriteForKeyword('刷新测试').entry.lastRefreshAt);
} finally {
TencentSource.prototype.search = originalSearch;
TencentSource.prototype.handleAnimes = originalHandleAnimes;
Globals.envs.sourceOrderArr = originalOrder;
}
});
await t.test('frontend bundle contains working favorite controls', () => {
assert.match(HTML_TEMPLATE, /id="manual-favorite-btn"/);
assert.doesNotMatch(HTML_TEMPLATE, /id="auto-favorite-btn"/);
assert.match(HTML_TEMPLATE, /id="favorite-panel"/);
assert.match(HTML_TEMPLATE, /switchDanmuTestTab\('favorite'/);
assert.match(apitestJsContent, /function favoriteManualSearch\(\)/);
assert.match(apitestJsContent, /function setManualFavoriteButton/);
assert.match(apitestJsContent, /取消收藏 · /);
assert.match(apitestJsContent, /removing \? '\/api\/v2\/favorite\/remove'/);
assert.match(apitestJsContent, /JSON\.stringify\(\{ keyword \}\)/);
assert.match(apitestJsContent, /\/api\/v2\/favorite\/refresh/);
assert.match(apitestJsContent, /\/api\/v2\/favorite\/remove/);
assert.match(apitestJsContent, /最近刷新时间:/);
assert.doesNotMatch(systemSettingsJsContent, /switchCategory\('favorite'\)/);
assert.doesNotThrow(() => new Function(apitestJsContent));
assert.doesNotThrow(() => new Function(systemSettingsJsContent));
});
});
// await t.test('GET /api/v2/comment/:id?format=json&duration=true should return segment duration and reuse comment cache', async () => {
// Globals.init({});
// Globals.animes = [];