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 \` +
+
+ \${imageUrl ? '' : ''} +
+ \${title} +
来源:\${source} · \${episodeText} · \${item.resultsCount || 0} 个搜索结果
+
收藏时间:\${escapeHtml(formatFavoriteTime(item.timestamp))}
+
最近刷新时间:\${escapeHtml(formatFavoriteTime(item.lastRefreshAt || item.timestamp))}
+
\${scheduleMeta}
+
+
+
+ + + +
+
+ \`; +} + +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 = '

正在加载收藏...

'; + return; + } + if (favoriteState.error) { + if (status) status.textContent = '收藏 · 加载失败'; + list.innerHTML = \`
收藏加载失败\${escapeHtml(favoriteState.error)}
\`; + 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('') + : '
暂无收藏请先在手动匹配测试中添加收藏
'; +} + +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, { diff --git a/danmu_api/ui/js/main.js b/danmu_api/ui/js/main.js index 7264076..3f89e8f 100644 --- a/danmu_api/ui/js/main.js +++ b/danmu_api/ui/js/main.js @@ -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(); diff --git a/danmu_api/ui/js/systemsettings.js b/danmu_api/ui/js/systemsettings.js index 2a46667..bf1e5a8 100644 --- a/danmu_api/ui/js/systemsettings.js +++ b/danmu_api/ui/js/systemsettings.js @@ -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; diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js index 61451cc..96d40d8 100644 --- a/danmu_api/ui/template.js +++ b/danmu_api/ui/template.js @@ -35,7 +35,7 @@ export const HTML_TEMPLATE = /* html */ `