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:
1 parent
8ff78add1e
commit
838b5685ac
21 files changed
+1663
-48
No files matched your search
@@ -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` 依赖。
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
// 示例返回
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'), // 代理/反代地址
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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) ============ */
|
||||
|
||||
@@ -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
@@ -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, {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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 = [];
|
||||
|
||||
Reference in new issue
Block a user