diff --git a/README.md b/README.md
index 766378c..2adb45d 100644
--- a/README.md
+++ b/README.md
@@ -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生效,支持格式:
正常代理:`http://127.0.0.1:7890`
万能反代:`@http://127.0.0.1`
特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili,animeko`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)
混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用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` 依赖。
diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js
index 1734d65..1aa3de5 100644
--- a/danmu_api/apis/dandan-api.js
+++ b/danmu_api/apis/dandan-api.js
@@ -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}`);
// 示例返回
diff --git a/danmu_api/apis/favorite-api.js b/danmu_api/apis/favorite-api.js
new file mode 100644
index 0000000..66c0850
--- /dev/null
+++ b/danmu_api/apis/favorite-api.js
@@ -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);
+ }
+}
diff --git a/danmu_api/apis/system-api.js b/danmu_api/apis/system-api.js
index 24e100f..983bbe8 100644
--- a/danmu_api/apis/system-api.js
+++ b/danmu_api/apis/system-api.js
@@ -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() {
diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js
index 2245c53..484b65a 100644
--- a/danmu_api/configs/envs.js
+++ b/danmu_api/configs/envs.js
@@ -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'), // 代理/反代地址
diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js
index 04c7747..c308a37 100644
--- a/danmu_api/configs/globals.js
+++ b/danmu_api/configs/globals.js
@@ -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
diff --git a/danmu_api/server.js b/danmu_api/server.js
index 8c21641..70bf3f2 100644
--- a/danmu_api/server.js
+++ b/danmu_api/server.js
@@ -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();
diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js
index ade96b2..704b8ba 100644
--- a/danmu_api/ui/css/components.css.js
+++ b/danmu_api/ui/css/components.css.js
@@ -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;
diff --git a/danmu_api/ui/css/responsive.css.js b/danmu_api/ui/css/responsive.css.js
index aec6ea7..ef304e1 100644
--- a/danmu_api/ui/css/responsive.css.js
+++ b/danmu_api/ui/css/responsive.css.js
@@ -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) ============ */
diff --git a/danmu_api/ui/css/themes.css.js b/danmu_api/ui/css/themes.css.js
index 3e47f31..83352fe 100644
--- a/danmu_api/ui/css/themes.css.js
+++ b/danmu_api/ui/css/themes.css.js
@@ -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; }
diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js
index 448755b..a443b3f 100644
--- a/danmu_api/ui/js/apitest.js
+++ b/danmu_api/ui/js/apitest.js
@@ -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 \`
+
正在加载收藏...
'; + return; + } + if (favoriteState.error) { + if (status) status.textContent = '收藏 · 加载失败'; + list.innerHTML = \`