From b811f8fd1784f2c12b68041ab2a8a6eca6961ebe Mon Sep 17 00:00:00 2001 From: huangxd- <649924757@qq.com> Date: Sun, 9 Aug 2026 12:23:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(match):=20=E6=94=AF=E6=8C=81=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8C=B9=E9=85=8D=E6=98=A0=E5=B0=84=E8=A1=A8=E8=A7=84?= =?UTF-8?q?=E5=88=99=20(#430)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(favorite): 重构收藏匹配逻辑,为搜索场景独立实现解析函数 - 将原 `resolveFavoriteForKeyword` 重命名为 `resolveFavoriteForSearchKeyword`,专门用于搜索场景的收藏命中判断 - 新增 `resolveFavoriteForSearchKeyword` 实现精确标题匹配,避免短关键词误复用更长标题的收藏结果 - 更新所有调用点(缓存有效性、搜索接口、匹配接口)使用新函数 - 版本号升级至 v1.20.5 此变更解决了因关键词长度差异导致收藏缓存错误命中其他剧集的问题,确保搜索缓存和收藏优先级判断更准确。 * feat(ui): 将弹幕测试设为接口调试页面的默认标签页 交换了顶部标签按钮的顺序,并将对应的内容区域设为激活状态,使页面加载时默认显示弹幕测试功能,提升测试场景的使用便捷性。 * feat(match): 支持自动匹配映射表规则,可跨季映射并指定平台/结果优先级 - 新增 `AUTO_MATCH_MAPPING_TABLE` 环境变量,支持开放映射(源季递增)和有限范围映射(等长范围),并支持目标结果优选(含年份/类型/平台) - 更新 README 和 `.env.example` 中的配置说明,明确映射优先级为:当前源季手动偏好 > 本映射表 > `TITLE_MAPPING_TABLE` > 普通匹配 - 优化 `REMEMBER_LAST_SELECT` 描述,澄清仅记录明确手动选择,自动匹配后直接返回不会写入偏好 * feat: 预览数组值显示为逗号分隔字符串 在预览界面中,当数据字段为数组类型时,原先会进入对象处理逻辑导致显示不友好。 现增加对数组类型的单独处理,将其转换为逗号分隔的字符串,提升预览可读性。 * fix: 修复标签拖拽触摸支持并清理历史脏数据 - 为表单控件添加 touch-action 样式,改善移动端交互体验 - 关闭模态框时主动取消触摸拖拽状态,防止事件残留 - 选中值渲染时对逗号分隔项进行去重,清理历史重复脏数据 - 重构标签元素获取逻辑,统一排除拖拽幽灵元素 - 为待选标签添加 touchcancel 事件处理,完善触摸拖拽生命周期 - 实现基于坐标的选中标签拖拽目标检测,提升拖拽定位准确性 - 拖拽结束后清空内部引用,避免内存泄漏 --- README.md | 6 +- config/.env.example | 17 +- danmu_api/apis/dandan-api.js | 335 +++++++++++----- danmu_api/configs/envs.js | 12 +- danmu_api/configs/globals.js | 2 +- danmu_api/ui/css/components.css.js | 16 +- danmu_api/ui/css/forms.css.js | 4 +- danmu_api/ui/js/apitest.js | 2 +- danmu_api/ui/js/main.js | 3 + danmu_api/ui/js/preview.js | 5 +- danmu_api/ui/js/systemsettings.js | 420 ++++++++++++--------- danmu_api/ui/template.js | 8 +- danmu_api/utils/auto-match-mapping-util.js | 205 ++++++++++ danmu_api/utils/cache-util.js | 61 ++- danmu_api/utils/favorite-util.js | 21 ++ danmu_api/worker.test.js | 311 ++++++++++++++- 16 files changed, 1146 insertions(+), 282 deletions(-) create mode 100644 danmu_api/utils/auto-match-mapping-util.js diff --git a/README.md b/README.md index 2adb45d..1834d48 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ LogVar 弹幕 API 服务器 ## 功能 - **API 接口**: - `GET /api/v2/search/anime?keyword=${queryTitle}`:根据关键字搜索动漫。 - - `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`;已支持从网盘资源命名,如`无忧渡.S01E01.2160p.WEB-DL.H265.DDP.5.1`中提取 title/season/episode);已支持外语标题匹配,如`Blood.River.S01E05`,需配置环境变量`TITLE_TO_CHINESE`使用;已适配该格式`爱情公寓.ipartment.2009.S03E05.H.265.25fps.mkv`标题;已支持AI自动匹配,需配合AI相关环境变量使用 + - `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`;已支持从网盘资源命名,如`无忧渡.S01E01.2160p.WEB-DL.H265.DDP.5.1`中提取 title/season/episode);可通过 `AUTO_MATCH_MAPPING_TABLE` 配置跨标题、跨季和集数范围映射;已支持外语标题匹配,如`Blood.River.S01E05`,需配置环境变量`TITLE_TO_CHINESE`使用;已适配该格式`爱情公寓.ipartment.2009.S03E05.H.265.25fps.mkv`标题;已支持AI自动匹配,需配合AI相关环境变量使用 - `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。 - `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。 - `GET /api/v2/comment/:commentId?format=json&duration=true`:获取指定弹幕评论;当 `duration=true` 且返回 JSON 时,会额外附带 `videoDuration` 字段,优先返回源站时长,拿不到时返回 `0`。 @@ -463,6 +463,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | STRICT_TITLE_MATCH | 【可选】是否启用严格标题匹配模式,默认为`false`(宽松模糊匹配),启用后只匹配标题开头或完全匹配的结果。例如:搜索"遮天"时,`false`会匹配"古惑仔3之只手遮天",`true`只匹配"遮天"、"遮天 第一季"等。可选值:`true`、`false` | | TITLE_TO_CHINESE | 【可选】是否在match自动匹配时将外语标题转换成中文标题,适用于网盘没有刮削的资源,默认值:false(不转换),说明:需配合TMDB_API_KEY使用 | | TITLE_MAPPING_TABLE | 【可选】剧名映射表,用于自动匹配时替换标题进行搜索,格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华" | +| AUTO_MATCH_MAPPING_TABLE | 【可选】自动匹配映射表,仅作用于 `POST /api/v2/match`,多条规则用分号分隔。开放映射 `永生 S05E02 -> 永生 S01E58` 会在源第 5 季内按集数递增映射;有限范围 `永生 S05E02~03 -> 永生 S01E58~59` 只映射包含两端的等长范围。支持目标结果优选 `海贼王 S02E01 -> 航海王(1999)【动漫】 S01E62` 和平台优选 `航海王 S01E01 -> 航海王 S01E01 @qiyi`。同一输入优先采用有限范围规则,同类型冲突按配置顺序;整体优先级为当前源季手动偏好 > 本映射表 > `TITLE_MAPPING_TABLE` > 普通匹配,`default` 偏好不阻断映射。限定候选不可用时回退同目标标题,映射目标失败时按原始请求重新匹配。普通搜索、收藏缓存和弹幕时间偏移不受影响。 | | TITLE_NOISE_FILTER | 【可选】剧名杂音清理规则,按正则表达式清理搜索与匹配阶段的剧名杂音词(如`百花杀(真彩)`→`百花杀`),默认值如下,设为空值可禁用 | | ANIME_TITLE_SIMPLIFIED | 【可选】是否在搜索时将繁体剧名标题自动转换为简体,适用于繁体标题搜索,默认值:false(不转换),可选值:`true`、`false` | | BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 | @@ -485,7 +486,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | COMMENT_CACHE_MINUTES | 【可选】弹幕缓存时间(分钟),默认为`3`,弹幕数据的缓存时间,独立于搜索结果缓存,设置为`0`表示不缓存 | | COMMENT_CACHE_MIN_COUNT | 【可选】弹幕缓存最少条数,默认为`100`。缓存弹幕少于该数量时忽略缓存时间并重新获取最新弹幕,设置为`0`可关闭此机制 | | HONGGUO_MERGE_ALL_EPISODES | 【可选】红果短剧是否将所有集弹幕按集号合并为一集返回,默认为`false`。启用后每集弹幕时间会累加前面各集时长,并在剧集列表中显示为“全集” | -| REMEMBER_LAST_SELECT | 【可选】是否记住手动选择结果,用于match自动匹配时优选上次的选择,默认为`true`,表示记住,请注意,该功能为实验性功能,会记住某个剧上次选择的结果作为下次自动匹配的优选,如不需要,请关闭 | +| REMEMBER_LAST_SELECT | 【可选】是否记住明确手动选择的结果,用于match自动匹配时优选上次的选择,默认为`true`。自动匹配后直接获取其返回结果不会写入偏好,选择不同结果时才会记录;如不需要,请关闭 | | 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`)' | @@ -697,6 +698,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 │ └── utils/ │ ├── ai-util.js # AI相关处理工具 │ ├── aiyifan-util.js # 爱壹帆签名工具 +│ ├── auto-match-mapping-util.js # 自动匹配映射规则解析与候选筛选工具 │ ├── bangumi-data-util.js # Bangumi Data管理工具 │ ├── cache-util.js # 缓存数据处理工具 │ ├── codec-util.js # 编解码工具 diff --git a/config/.env.example b/config/.env.example index b1f202a..c0b2ab9 100644 --- a/config/.env.example +++ b/config/.env.example @@ -324,8 +324,8 @@ HONGGUO_MERGE_ALL_EPISODES=false # 是否记住手动选择结果 # 默认值:true -# 说明:是否记住手动选择结果,用于match自动匹配时优选上次的选择 -# 用于存储查询关键字上次选择的 animeId,下次 match 自动匹配时优先选择该 anime +# 说明:是否记住明确手动选择的结果,用于match自动匹配时优选上次的选择 +# 自动匹配后直接获取其返回结果不会写入偏好,选择不同结果时才会记录 REMEMBER_LAST_SELECT=true # 用户偏好记忆缓存大小限制 @@ -391,6 +391,19 @@ BANGUMI_DATA_CACHE_DAYS=7 # 默认值:空(不启用标题映射) TITLE_MAPPING_TABLE= +# 自动匹配映射表(仅作用于 POST /api/v2/match) +# 默认值:空(不启用自动匹配映射) +# 说明:多个规则用分号(;)分隔,源标题按规范化后的完整标题精确匹配,支持 S5E2 和 S05E02 +# 开放映射:从源季起始集开始递增映射,但不会延伸到下一季 +# 有限范围:箭头两侧都必须填写范围且长度相同,范围包含两端;范围外保持原始匹配 +# 目标可使用 (年份)、【类型】和 @平台 优选结果;限定结果或映射目标不可用时会自动回退 +# 优先级:当前源季的手动选择偏好 > AUTO_MATCH_MAPPING_TABLE > TITLE_MAPPING_TABLE > 普通自动匹配 +# 开放映射:永生 S05E02 -> 永生 S01E58 +# 有限范围:永生 S05E02~03 -> 永生 S01E58~59 +# 指定结果:海贼王 S02E01 -> 航海王(1999)【动漫】 S01E62 +# 指定平台:航海王 S01E01 -> 航海王 S01E01 @qiyi +AUTO_MATCH_MAPPING_TABLE= + # 是否在搜索时将繁体剧名标题自动转换为简体,适用于繁体标题搜索 # 默认值:false(不转换) # 可选值:true, false diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 1aa3de5..c02098a 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -6,12 +6,13 @@ import { setRedisKey, updateRedisCaches } from "../utils/redis-util.js"; import { setLocalRedisKey, updateLocalRedisCaches } from "../utils/local-redis-util.js"; import { setCommentCache, addAnime, findAnimeIdByCommentId, findTitleById, findUrlById, getCommentCache, getPreferAnimeId, - getSearchCache, removeEarliestAnime, resolveAnimeById, resolveAnimeByIdFromDetailStore, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile, - updateLocalCaches, setLastSearch, getLastSearch, findAnimeTitleById, findIndexById + getSearchCache, removeEarliestAnime, resolveAnimeById, resolveAnimeByIdFromDetailStore, setPreferByAnimeId, setPreferForTitle, setSearchCache, storeAnimeIdsToMap, writeCacheToFile, + updateLocalCaches, setLastSearch, getLastSearch, findAnimeTitleById, findIndexById, hasSeasonSpecificPreference, hasLegacySeasonPreference } from "../utils/cache-util.js"; -import { resolveFavoriteForKeyword } from "../utils/favorite-util.js"; +import { resolveFavoriteForSearchKeyword } from "../utils/favorite-util.js"; import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js"; import { resolveOffset, resolveOffsetRule, applyOffset } from "../utils/offset-util.js"; +import { filterMappingQualifierCandidates, filterMappingTargetCandidates, resolveAutoMatchMapping } from "../utils/auto-match-mapping-util.js"; import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, extractYear, titleMatches, extractAnimeInfo, extractEpisodeNumberFromTitle, extractSeasonNumberFromAnimeTitle, extractAnimeTitle @@ -514,7 +515,7 @@ export async function searchAnime(url, preferAnimeId = null, preferSource = null const cacheKey = querySeason !== null ? `${queryTitle}_S${querySeason}` : queryTitle; // 收藏缓存命中后必须直接返回,不能因目标集数判断继续请求外部源。 - if (!forceRefresh && resolveFavoriteForKeyword(cacheKey)) { + if (!forceRefresh && resolveFavoriteForSearchKeyword(cacheKey)) { const favoriteResults = getSearchCache(cacheKey, requestAnimeDetailsMap) || []; return jsonResponse({ errorCode: 0, @@ -1704,6 +1705,143 @@ export function buildSearchAnimeUrl(baseUrl, keyword, season, episode) { return searchUrl; } +async function selectAnimeMatch({ season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId, offsets, detailStore }) { + let resAnime = null; + let resEpisode = null; + let spilloverMatched = false; + + const aiMatchResult = await matchAniAndEpByAi( + season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId, detailStore + ); + if (aiMatchResult.resAnime && aiMatchResult.resEpisode) { + return { resAnime: aiMatchResult.resAnime, resEpisode: aiMatchResult.resEpisode, spilloverMatched: false }; + } + + for (const platform of dynamicPlatformOrder) { + const matched = await matchAniAndEp( + season, episode, year, searchData, title, req, platform, preferAnimeId, offsets, detailStore + ); + resEpisode = matched.resEpisode; + resAnime = matched.resAnime; + if (resAnime) { + log("info", `[system] [match] Found match with platform: ${platform || 'default'}`); + break; + } + } + + if (!resAnime) { + const fallback = await fallbackMatchAniAndEp( + searchData, req, season, episode, year, title, resEpisode, resAnime, offsets, detailStore + ); + resEpisode = fallback.resEpisode; + resAnime = fallback.resAnime; + spilloverMatched = fallback.isSpillover; + } + + return { resAnime, resEpisode, spilloverMatched }; +} + +function createMatchPlatformOrder(preferredPlatform, secondaryPreferredPlatform = null) { + const dynamicPlatformOrder = createDynamicPlatformOrder(preferredPlatform); + if (!secondaryPreferredPlatform || secondaryPreferredPlatform === preferredPlatform || + !globals.allowedPlatforms.includes(secondaryPreferredPlatform)) { + return dynamicPlatformOrder; + } + + const withoutSecondary = dynamicPlatformOrder.filter(platform => platform !== secondaryPreferredPlatform); + withoutSecondary.splice(preferredPlatform ? 1 : 0, 0, secondaryPreferredPlatform); + return withoutSecondary; +} + +async function executeMatchAttempt({ req, title, season, episode, year, preferredPlatform, secondaryPreferredPlatform, preferAnimeId, preferSource, offsets, mapping }) { + const dynamicPlatformOrder = createMatchPlatformOrder(preferredPlatform, secondaryPreferredPlatform); + const targetPlatform = dynamicPlatformOrder.length > 0 ? dynamicPlatformOrder[0] : null; + const detailStore = new Map(); + const searchUrl = buildSearchAnimeUrl(req.url, title, season, episode); + const searchRes = await searchAnime(searchUrl, preferAnimeId, preferSource, detailStore, targetPlatform); + const searchData = await searchRes.json(); + log("info", `[system] [match] searchData: ${searchData.animes}`); + log("info", `[system] [match] Dynamic platformOrder: ${dynamicPlatformOrder}`); + log("info", `[system] [match] Preferred platform: ${preferredPlatform || 'none'}`); + + if (!searchData?.success || !Array.isArray(searchData.animes) || searchData.animes.length === 0) { + return { resAnime: null, resEpisode: null, spilloverMatched: false, title, season, episode }; + } + + const targetCandidates = mapping ? filterMappingTargetCandidates(searchData.animes, mapping) : searchData.animes; + if (mapping && targetCandidates.length === 0) { + return { resAnime: null, resEpisode: null, spilloverMatched: false, title, season, episode }; + } + + const targetSearchData = { ...searchData, animes: targetCandidates }; + const candidatePasses = []; + if (mapping?.targetYear || mapping?.targetType) { + const qualified = filterMappingQualifierCandidates(targetCandidates, mapping); + if (qualified.length > 0) { + candidatePasses.push({ + searchData: { ...searchData, animes: qualified }, + year: mapping.targetYear || null, + label: 'qualified' + }); + } + } + candidatePasses.push({ searchData: targetSearchData, year: mapping ? null : year, label: 'fallback' }); + + for (const pass of candidatePasses) { + if (mapping && pass.label === 'fallback' && (mapping.targetYear || mapping.targetType)) { + log('info', `[system] [auto-match-mapping] Relaxing target qualifiers for "${mapping.targetDisplayTitle}" while keeping the target title`); + } + const selected = await selectAnimeMatch({ + season, + episode, + year: pass.year, + searchData: pass.searchData, + title, + req, + dynamicPlatformOrder, + preferAnimeId, + offsets, + detailStore + }); + if (selected.resAnime && selected.resEpisode) { + if (mapping && pass.label === 'qualified') { + log('info', `[system] [auto-match-mapping] Matched preferred target qualifiers for "${mapping.targetDisplayTitle}"`); + } + return { ...selected, title, season, episode }; + } + } + + return { resAnime: null, resEpisode: null, spilloverMatched: false, title, season, episode }; +} + +function normalizeMatchTitle(title) { + let normalized = String(title || '').trim(); + if (globals.animeTitleSimplified) normalized = simplized(normalized); + if (globals.titleNoiseFilter) normalized = normalized.replace(globals.titleNoiseFilter, '').trim(); + return normalized; +} + +function resolveLegacyMatchTitle(title) { + const mapped = globals.titleMappingTable instanceof Map ? globals.titleMappingTable.get(title) : null; + if (mapped) log("info", `[system] [match] Title mapped from original: ${title} to: ${mapped}`); + return normalizeMatchTitle(mapped || title); +} + +function findSeasonPreferenceTitle(titles, season) { + if (!globals.rememberLastSelect) return null; + for (const title of titles) { + if (title && hasSeasonSpecificPreference(title, season)) return title; + } + return null; +} + +function findLegacySeasonPreferenceTitle(titles, season) { + for (const title of titles) { + if (title && hasLegacySeasonPreference(title, season)) return title; + } + return null; +} + // Extracted function for POST /api/v2/match export async function matchAnime(url, req, clientIp) { try { @@ -1735,46 +1873,78 @@ export async function matchAnime(url, req, clientIp) { log("info", `[system] [match] Processing anime match for query: ${fileName}`); log("info", `[system] [match] Parsed cleanFileName: ${cleanFileName}, preferredPlatform: ${preferredPlatform}`); - let {title, season, episode, year} = await extractTitleSeasonEpisode(cleanFileName); + const parsed = await extractTitleSeasonEpisode(cleanFileName); + const originalTitle = normalizeMatchTitle(parsed.title); + const originalSeason = parsed.season; + const originalEpisode = parsed.episode; + const originalYear = parsed.year; - // 使用剧名映射表转换剧名 - if (globals.titleMappingTable && globals.titleMappingTable.size > 0) { - const mappedTitle = globals.titleMappingTable.get(title); - if (mappedTitle) { - title = mappedTitle; - log("info", `[system] [match] Title mapped from original: ${url.searchParams.get("keyword")} to: ${title}`); + const preferenceTitles = [...new Set([originalTitle, parsed.title].filter(Boolean))]; + const configuredMapping = resolveAutoMatchMapping(globals.autoMatchMappingTable, { + title: originalTitle, + season: originalSeason, + episode: originalEpisode + }); + const manualPreferenceTitle = findSeasonPreferenceTitle(preferenceTitles, originalSeason); + const mapping = manualPreferenceTitle ? null : configuredMapping; + if (configuredMapping && manualPreferenceTitle) { + log('info', `[system] [auto-match-mapping] Explicit manual preference for "${manualPreferenceTitle}" S${originalSeason} overrides rule "${configuredMapping.raw}"`); + } else if (configuredMapping) { + const legacyPreferenceTitle = findLegacySeasonPreferenceTitle(preferenceTitles, originalSeason); + if (legacyPreferenceTitle) { + log('info', `[system] [auto-match-mapping] Ignoring unmarked legacy preference for "${legacyPreferenceTitle}" S${originalSeason}`); } } - // 如果启用了搜索关键字繁转简,则进行转换 - if (globals.animeTitleSimplified) { - const simplifiedTitle = simplized(title); - log("info", `[system] [match] matchAnime converted traditional to simplified: ${title} -> ${simplifiedTitle}`); - title = simplifiedTitle; + let attempt; + let mappingApplied = false; + + if (mapping) { + const mappedTitle = normalizeMatchTitle(mapping.targetTitle); + const mappedPlatform = mapping.targetPlatform || preferredPlatform; + log('info', `[system] [auto-match-mapping] ${originalTitle} S${originalSeason}E${originalEpisode} -> ${mappedTitle} S${mapping.targetSeason}E${mapping.targetEpisode}${mapping.targetPlatform ? ` @${mapping.targetPlatform}` : ''}`); + attempt = await executeMatchAttempt({ + req, + title: mappedTitle, + season: mapping.targetSeason, + episode: mapping.targetEpisode, + year: mapping.targetYear, + preferredPlatform: mappedPlatform, + secondaryPreferredPlatform: mapping.targetPlatform ? preferredPlatform : null, + preferAnimeId: null, + preferSource: null, + offsets: null, + mapping + }); + mappingApplied = Boolean(attempt.resAnime && attempt.resEpisode); + if (!mappingApplied) { + log('warn', `[system] [auto-match-mapping] Target failed for "${mapping.raw}", falling back to original match`); + } } - // 剧名杂音清理:移除画质/配音/版本等杂音词 - if (globals.titleNoiseFilter) { - title = title.replace(globals.titleNoiseFilter, '').trim(); + if (!mappingApplied) { + const title = manualPreferenceTitle || resolveLegacyMatchTitle(parsed.title); + const preferenceKey = manualPreferenceTitle || title; + const [preferAnimeId, preferSource, offsets] = globals.rememberLastSelect + ? getPreferAnimeId(preferenceKey, originalSeason) + : [null, null, null]; + log("info", `[system] [match] prefer animeId: ${preferAnimeId} from ${preferSource}`); + attempt = await executeMatchAttempt({ + req, + title, + season: originalSeason, + episode: originalEpisode, + year: originalYear, + preferredPlatform, + secondaryPreferredPlatform: null, + preferAnimeId, + preferSource, + offsets, + mapping: null + }); } - // 获取 prefer animeId(按 season 维度) - const [preferAnimeId, preferSource, offsets] = getPreferAnimeId(title, season); - log("info", `[system] [match] prefer animeId: ${preferAnimeId} from ${preferSource}`); - - // 根据指定平台创建动态平台顺序 - const dynamicPlatformOrder = createDynamicPlatformOrder(preferredPlatform); - const targetPlatform = dynamicPlatformOrder.length > 0 ? dynamicPlatformOrder[0] : null; - - const requestAnimeDetailsMap = new Map(); - let originSearchUrl = buildSearchAnimeUrl(req.url, title, season, episode); - const searchRes = await searchAnime(originSearchUrl, preferAnimeId, preferSource, requestAnimeDetailsMap, targetPlatform); - const searchData = await searchRes.json(); - log("info", `[system] [match] searchData: ${searchData.animes}`); - - let resAnime; - let resEpisode; - let spilloverMatched = false; + const { resAnime, resEpisode, spilloverMatched } = attempt; let resData = { "errorCode": 0, @@ -1784,47 +1954,23 @@ export async function matchAnime(url, req, clientIp) { "matches": [] }; - log("info", `[system] [match] Original platformOrderArr: ${globals.platformOrderArr}`); - log("info", `[system] [match] Dynamic platformOrder: ${dynamicPlatformOrder}`); - log("info", `[system] [match] Preferred platform: ${preferredPlatform || 'none'}`); - - // 尝试使用AI进行匹配 - const aiMatchResult = await matchAniAndEpByAi(season, episode, year, searchData, title, req, dynamicPlatformOrder, preferAnimeId, requestAnimeDetailsMap); - if (aiMatchResult.resAnime && aiMatchResult.resEpisode) { - resAnime = aiMatchResult.resAnime; - resEpisode = aiMatchResult.resEpisode; - resData["isMatched"] = true; - log("info", `[system] [match] AI match found: ${resAnime.animeTitle}; episode: ${resEpisode.episodeTitle}`); - } else { - // AI匹配失败或未配置,使用传统匹配方式 - for (const platform of dynamicPlatformOrder) { - const __ret = await matchAniAndEp(season, episode, year, searchData, title, req, platform, preferAnimeId, offsets, requestAnimeDetailsMap); - resEpisode = __ret.resEpisode; - resAnime = __ret.resAnime; - - if (resAnime) { - resData["isMatched"] = true; - log("info", `[system] [match] Found match with platform: ${platform || 'default'}`); - break; - } - } - - // 如果都没有找到则返回第一个满足剧集数的剧集 - if (!resAnime) { - const __ret = await fallbackMatchAniAndEp(searchData, req, season, episode, year, title, resEpisode, resAnime, offsets, requestAnimeDetailsMap); - resEpisode = __ret.resEpisode; - resAnime = __ret.resAnime; - // 跨季集数顺延映射的结果不更新 lastSearch,防止无关番剧污染 lastSelectMap 偏好记录 - spilloverMatched = __ret.isSpillover; - if (resAnime) { - resData["isMatched"] = true; - } - } - } + resData["isMatched"] = Boolean(resAnime && resEpisode); if (resEpisode) { if (clientIp && !spilloverMatched) { - setLastSearch(clientIp, { title, season, episode, episodeId: resEpisode.episodeId }); + setLastSearch(clientIp, mappingApplied ? { + title: originalTitle, + season: originalSeason, + episode: originalEpisode, + episodeId: resEpisode.episodeId, + autoMatchMappingApplied: true, + mappingTargetTitle: mapping.targetTitle + } : { + title: attempt.title, + season: attempt.season, + episode: attempt.episode, + episodeId: resEpisode.episodeId + }); } resData["matches"] = [ AnimeMatch.fromJson({ @@ -1841,8 +1987,10 @@ 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)) { + const favoriteTitle = mappingApplied ? originalTitle : attempt.title; + const favoriteSeason = mappingApplied ? originalSeason : attempt.season; + const favoriteKey = favoriteSeason !== null ? `${favoriteTitle}_S${favoriteSeason}` : favoriteTitle; + if (resolveFavoriteForSearchKeyword(favoriteKey)) { resData["matches"] = resData["matches"].map(m => ({ ...m, isFavorite: true })); } } @@ -2431,17 +2579,25 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu let lastTitle = null; let lastSeason = null; let offset = null; + let lastSearchContext = null; - if (clientIp) { + if (clientIp && globals.rememberLastSelect) { const lastSearch = getLastSearch(clientIp); // lastSearch 仅在 Match/Search 时更新,小幻顺播下一集时不刷新。 // 加 3 分钟 TTL 防止过期 episode 值导致错误偏移(如 E21 时搜的,20 分钟后顺播到 E24 时仍以 E21 为基准记录)。 if (lastSearch && lastSearch.title && lastSearch.season && lastSearch.episode && episodeTitle) { if (Date.now() - lastSearch.timestamp < 180000) { - lastTitle = lastSearch.title; - lastSeason = lastSearch.season; - offset = `${lastSearch.episode}:${episodeTitle}`; - log("info", `[system] [LogVar-API] Calculated episode offset for IP ${clientIp}: Query E${lastSearch.episode}, Selected ${episodeTitle} -> Offset ${offset} (Season ${lastSeason})`); + lastSearchContext = lastSearch; + const isAutomaticResult = lastSearch.episodeId !== null && lastSearch.episodeId !== undefined && + String(lastSearch.episodeId) === String(commentId); + if (isAutomaticResult) { + log('info', `[system] [match] Skip preference write for automatically returned episode ${commentId}`); + } else { + lastTitle = lastSearch.title; + lastSeason = lastSearch.season; + offset = `${lastSearch.episode}:${episodeTitle}`; + log("info", `[system] [LogVar-API] Calculated episode offset for IP ${clientIp}: Query E${lastSearch.episode}, Selected ${episodeTitle} -> Offset ${offset} (Season ${lastSeason})`); + } } } } @@ -2451,11 +2607,20 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu // 校验番剧标题或别名是否匹配最新搜索/匹配上下文,别名检查用于兼容不同源对同一番剧的标题命名差异 // 偏好记录使用非严格匹配(forceNonStrict=true),因为用户手动选择不应受严格标题匹配限制 const titleOrAliasMatches = titleMatches(animeTitle, lastTitle, null, true) || - (Array.isArray(animeAliases) && animeAliases.some(alias => titleMatches(alias, lastTitle, null, true))); + (Array.isArray(animeAliases) && animeAliases.some(alias => titleMatches(alias, lastTitle, null, true))) || + (lastSearchContext?.autoMatchMappingApplied && ( + titleMatches(animeTitle, lastSearchContext.mappingTargetTitle, null, true) || + (Array.isArray(animeAliases) && animeAliases.some(alias => titleMatches(alias, lastSearchContext.mappingTargetTitle, null, true))) + )); - if (titleOrAliasMatches) { - log("info", `[system] [LogVar-API] excute setPreferByAnimeId`); - setPreferByAnimeId(animeId, source, lastSeason, offset); + if (titleOrAliasMatches && lastTitle) { + if (lastSearchContext?.autoMatchMappingApplied) { + log("info", `[system] [auto-match-mapping] Saving explicit mapped-result correction for "${lastTitle}" S${lastSeason}`); + setPreferForTitle(lastTitle, animeId, source, lastSeason, offset); + } else { + log("info", `[system] [match] Saving explicit manual preference for "${lastTitle}" S${lastSeason}`); + setPreferByAnimeId(animeId, source, lastSeason, offset); + } } if (globals.localCacheValid && animeId) { diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 484b65a..e8692b8 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -4,6 +4,7 @@ */ import { danAnyFormats } from '../utils/dan-any.js'; import { parseOffsetRules } from '../utils/offset-util.js'; +import { parseAutoMatchMappingRules } from '../utils/auto-match-mapping-util.js'; export class Envs { static env; @@ -592,6 +593,13 @@ export class Envs { return mappingTable; } + static resolveAutoMatchMappingTable() { + const mappingStr = this.get('AUTO_MATCH_MAPPING_TABLE', '', 'string').trim(); + const { rules, warnings } = parseAutoMatchMappingRules(mappingStr, this.ALLOWED_PLATFORMS); + warnings.forEach(message => console.warn(`[auto-match-mapping] ${message}`)); + return rules; + } + /** * 获取记录的环境变量 JSON * @returns {Map} JSON 字符串 @@ -638,6 +646,7 @@ export class Envs { 'TITLE_TO_CHINESE': { category: 'match', type: 'boolean', description: '外语标题转换中文开关' }, 'ANIME_TITLE_SIMPLIFIED': { category: 'match', type: 'boolean', description: '搜索的剧名标题自动繁转简' }, 'TITLE_MAPPING_TABLE': { category: 'match', type: 'map', description: '剧名映射表,用于自动匹配时替换标题进行搜索,格式:原始标题->映射标题;原始标题->映射标题;... ,例如:"唐朝诡事录->唐朝诡事录之西行;国色芳华->锦绣芳华"' }, + 'AUTO_MATCH_MAPPING_TABLE': { category: 'match', type: 'map', description: '自动匹配映射表,仅作用于 POST /api/v2/match。多个规则使用分号分隔。\n开放映射:永生 S05E02 -> 永生 S01E58\n有限范围:永生 S05E02~03 -> 永生 S01E58~59\n指定结果:海贼王 S02E01 -> 航海王(1999)【动漫】 S01E62\n指定平台:航海王 S01E01 -> 航海王 S01E01 @qiyi' }, 'TITLE_NOISE_FILTER': { category: 'match', type: 'text', description: '剧名杂音清理规则,按正则表达式清理搜索与匹配阶段的剧名杂音词(如`百花杀(真彩)`→`百花杀`)。默认值:[((\\[](?:臻彩|真彩|高清|标清|超清|国配|中配|日配|粤语|原声|台配|无修|未删减|完整版|日语版|国语版|英语版|中字|字幕|助听|原版)[\\]))],中英文圆方括号均匹配。设为空值可禁用' }, 'AI_BASE_URL': { category: 'match', type: 'text', description: 'AI服务基础URL,不填默认为https://api.openai.com/v1' }, 'AI_MODEL': { category: 'match', type: 'text', description: 'AI模型名称,不填默认为gpt-4o' }, @@ -663,7 +672,7 @@ export class Envs { 'SEARCH_CACHE_MINUTES': { category: 'cache', type: 'number', description: '搜索结果缓存时间(分钟),默认3', min: 1, max: 120 }, 'COMMENT_CACHE_MINUTES': { category: 'cache', type: 'number', description: '弹幕缓存时间(分钟),默认3', min: 1, max: 120 }, 'COMMENT_CACHE_MIN_COUNT': { category: 'cache', type: 'number', description: '弹幕缓存最少条数,低于该值时重新获取,默认100,设置0关闭', min: 0, max: 10000 }, - 'REMEMBER_LAST_SELECT': { category: 'cache', type: 'boolean', description: '记住手动选择结果' }, + 'REMEMBER_LAST_SELECT': { category: 'cache', type: 'boolean', description: '记住明确手动选择的结果;自动匹配后直接获取其返回结果不会写入偏好' }, 'MAX_LAST_SELECT_MAP': { category: 'cache', type: 'number', description: '记住上次选择映射缓存大小限制,默认100', min: 10, max: 1000 }, 'MAX_ANIMES': { category: 'cache', type: 'number', description: '动漫标题缓存最大数量,默认100', min: 100, max: 1000 }, 'UPSTASH_REDIS_REST_URL': { category: 'cache', type: 'text', description: 'Upstash Redis请求链接' }, @@ -732,6 +741,7 @@ export class Envs { titleToChinese: this.get('TITLE_TO_CHINESE', false, 'boolean'), // 外语标题转换中文开关 animeTitleSimplified: this.get('ANIME_TITLE_SIMPLIFIED', false, 'boolean'), // 搜索的剧名标题自动繁转简 titleMappingTable: this.resolveTitleMappingTable(), // 剧名映射表,用于自动匹配时替换标题进行搜索 + autoMatchMappingTable: this.resolveAutoMatchMappingTable(), // 自动匹配标题/季度/集数映射规则 ipBlacklist: this.resolveIpBlacklist(), // IP 黑名单(支持正则) aiBaseUrl: this.get('AI_BASE_URL', 'https://api.openai.com/v1', 'string'), // AI服务基础URL aiModel: this.get('AI_MODEL', 'gpt-4o', 'string'), // AI模型名称 diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index c308a37..f6269f1 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.20.4', + VERSION: '1.20.5', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_RECORDS: 100, // 请求记录最大数量 diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js index 704b8ba..7610797 100644 --- a/danmu_api/ui/css/components.css.js +++ b/danmu_api/ui/css/components.css.js @@ -190,9 +190,10 @@ body[data-theme] .favorite-action-btn:disabled:hover { } .favorite-cover { - width: 58px; - height: 78px; + width: 78px; + height: auto; flex: 0 0 auto; + align-self: stretch; border-radius: 6px; object-fit: cover; background: #e5e7eb; @@ -200,11 +201,18 @@ body[data-theme] .favorite-action-btn:disabled:hover { .favorite-info-row { display: flex; - align-items: center; + align-items: stretch; gap: 12px; min-width: 0; } +.favorite-copy { + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; +} + .favorite-meta { color: #6b7280; font-size: 12px; @@ -2652,4 +2660,4 @@ body[data-theme$="-dark"] .heatmap-bar { .env-config-toolbar { /* 继承 preview-toolbar 样式 */ } -`; \ No newline at end of file +`; diff --git a/danmu_api/ui/css/forms.css.js b/danmu_api/ui/css/forms.css.js index 726fd77..28d7dbf 100644 --- a/danmu_api/ui/css/forms.css.js +++ b/danmu_api/ui/css/forms.css.js @@ -295,6 +295,7 @@ input:checked + .slider:before { white-space: normal; word-break: break-all; line-height: 1.4; + touch-action: none; } .selected-tag:hover { @@ -325,6 +326,7 @@ input:checked + .slider:before { font-size: 14px; font-weight: bold; transition: all 0.2s; + touch-action: manipulation; } .selected-tag .remove-btn:hover { @@ -565,4 +567,4 @@ input:checked + .slider:before { content: '—'; color: var(--theme-muted, #999); } -`; \ No newline at end of file +`; diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index a443b3f..a1e23d1 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -880,7 +880,7 @@ function renderFavoriteItem(item) {
\${imageUrl ? '' : ''} -
+
\${title}
来源:\${source} · \${episodeText} · \${item.resultsCount || 0} 个搜索结果
收藏时间:\${escapeHtml(formatFavoriteTime(item.timestamp))}
diff --git a/danmu_api/ui/js/main.js b/danmu_api/ui/js/main.js index 3f89e8f..a2995f4 100644 --- a/danmu_api/ui/js/main.js +++ b/danmu_api/ui/js/main.js @@ -541,6 +541,9 @@ function unlockPageScroll() { // 关闭模态框 function closeModal() { + if (typeof cleanupSelectedTagsTouchDrag === 'function') cleanupSelectedTagsTouchDrag(); + if (typeof handleStagingTouchCancel === 'function') handleStagingTouchCancel(); + var modal = document.getElementById('env-modal'); modal.classList.add('closing'); setTimeout(function() { diff --git a/danmu_api/ui/js/preview.js b/danmu_api/ui/js/preview.js index 46c3afb..304c5ca 100644 --- a/danmu_api/ui/js/preview.js +++ b/danmu_api/ui/js/preview.js @@ -23,7 +23,7 @@ const previewGroupDefinitions = { ], match: [ { name: '匹配策略', keys: ['PLATFORM_ORDER', 'STRICT_TITLE_MATCH', 'ENABLE_ANIME_EPISODE_FILTER'] }, - { name: '标题处理', keys: ['ANIME_TITLE_FILTER', 'EPISODE_TITLE_FILTER', 'TITLE_TO_CHINESE', 'ANIME_TITLE_SIMPLIFIED', 'TITLE_MAPPING_TABLE', 'TITLE_NOISE_FILTER'] }, + { name: '标题处理', keys: ['ANIME_TITLE_FILTER', 'EPISODE_TITLE_FILTER', 'TITLE_TO_CHINESE', 'ANIME_TITLE_SIMPLIFIED', 'TITLE_MAPPING_TABLE', 'AUTO_MATCH_MAPPING_TABLE', 'TITLE_NOISE_FILTER'] }, { name: 'AI 匹配', keys: ['AI_BASE_URL', 'AI_MODEL', 'AI_API_KEY', 'AI_MATCH_PROMPT'] }, { name: '动画元数据', keys: ['USE_BANGUMI_DATA'] } ], @@ -294,6 +294,9 @@ function getPreviewGroupName(category, key) { function formatPreviewValue(value) { if (value === null || value === undefined) return ''; + if (Array.isArray(value)) { + return value.join(','); + } if (typeof value === 'object') { try { return JSON.stringify(value, null, 2); diff --git a/danmu_api/ui/js/systemsettings.js b/danmu_api/ui/js/systemsettings.js index bf1e5a8..0409b5a 100644 --- a/danmu_api/ui/js/systemsettings.js +++ b/danmu_api/ui/js/systemsettings.js @@ -777,7 +777,10 @@ function renderValueInput(item) { const options = item && item.options ? item.options : ['option1', 'option2', 'option3', 'option4']; // 确保value是字符串类型后再进行split操作 const stringValue = typeof value === 'string' ? value : String(value || ''); - const selectedValues = stringValue ? stringValue.split(',').map(v => v.trim()).filter(v => v) : []; + // 排序配置中重复项没有语义,渲染时顺便清理历史脏数据。 + const selectedValues = stringValue + ? [...new Set(stringValue.split(',').map(v => v.trim()).filter(v => v))] + : []; // 检查是否为 SOURCE_ORDER,如果是则不显示合并模式 const shouldShowMergeMode = currentKey === 'MERGE_SOURCE_PAIRS' || currentKey === 'PLATFORM_ORDER'; @@ -1571,6 +1574,15 @@ function updateTagOptions() { } // 统一的状态检查函数 +function getSelectedTagElements() { + const container = document.getElementById('selected-tags'); + if (!container) return []; + + return Array.from(container.children).filter(element => + element.classList.contains('selected-tag') && !element.dataset.dragGhost + ); +} + function updateTagStates() { // 确保 DOM 元素存在,防止在渲染过程中被调用出错 // 确保当前编辑配置存在 @@ -1581,7 +1593,7 @@ function updateTagStates() { const stagingTokens = new Set(stagingTags); // 2. 获取已确认的 Selected Tags (仅在非合并模式下需要检查) - const selectedTagElements = Array.from(document.querySelectorAll('.selected-tag')); + const selectedTagElements = getSelectedTagElements(); // 3. 更新所有可选项的状态 const availableTags = document.querySelectorAll('.available-tag'); @@ -1788,6 +1800,7 @@ function setupStagingDragAndDrop() { tag.addEventListener('touchstart', handleStagingTouchStart); tag.addEventListener('touchmove', handleStagingTouchMove); tag.addEventListener('touchend', handleStagingTouchEnd); + tag.addEventListener('touchcancel', handleStagingTouchCancel); }); } @@ -1804,6 +1817,7 @@ function handleStagingDragEnd(e) { document.querySelectorAll('.staging-tag').forEach(tag => { tag.classList.remove('drag-over'); }); + stagingDraggedElement = null; } function handleStagingDragOver(e) { @@ -1918,7 +1932,7 @@ function handleStagingTouchEnd(e) { const touch = e.changedTouches[0]; const targetElement = document.elementFromPoint(touch.clientX, touch.clientY); - const targetTag = targetElement.closest('.staging-tag'); + const targetTag = targetElement ? targetElement.closest('.staging-tag') : null; if (targetTag && targetTag !== stagingDraggedElement) { const draggedIndex = parseInt(stagingDraggedElement.dataset.index); @@ -1942,45 +1956,175 @@ function handleStagingTouchEnd(e) { stagingDraggedElement = null; } +function handleStagingTouchCancel(e) { + if (e && e.cancelable) e.preventDefault(); + + const ghostElement = document.getElementById('staging-touch-drag-ghost'); + if (ghostElement) ghostElement.remove(); + + if (stagingDraggedElement) { + stagingDraggedElement.style.transform = ''; + stagingDraggedElement.style.opacity = ''; + stagingDraggedElement.style.zIndex = ''; + stagingDraggedElement.classList.remove('dragging'); + } + + document.querySelectorAll('#staging-area .staging-tag').forEach(tag => { + tag.classList.remove('drag-over'); + }); + stagingDraggedElement = null; +} + // 设置拖放功能 let draggedElement = null; let touchDragging = false; +let touchDragFrame = null; -// 为删除按钮添加触摸事件监听器,以确保其可以被点击 function setupDragAndDrop() { const container = document.getElementById('selected-tags'); - const tags = container.querySelectorAll('.selected-tag'); + if (!container) return; + if (container.dataset.dragEventsBound === 'true') return; + // 使用事件委托,让初始标签和运行时新增标签走同一套拖拽生命周期。 + container.addEventListener('dragstart', handleDelegatedDragStart); + container.addEventListener('dragend', handleDelegatedDragEnd); + container.addEventListener('dragover', handleDelegatedDragOver); + container.addEventListener('drop', handleDelegatedDrop); + container.addEventListener('dragenter', handleDelegatedDragEnter); + container.addEventListener('dragleave', handleDelegatedDragLeave); + container.addEventListener('touchstart', handleDelegatedTouchStart, { passive: false }); + container.dataset.dragEventsBound = 'true'; +} + +function getEventSelectedTag(e) { + const container = document.getElementById('selected-tags'); + const tag = e.target && e.target.closest ? e.target.closest('.selected-tag') : null; + return tag && container && container.contains(tag) && !tag.dataset.dragGhost ? tag : null; +} + +function handleDelegatedDragStart(e) { + const tag = getEventSelectedTag(e); + if (tag) handleDragStart.call(tag, e); +} + +function handleDelegatedDragEnd(e) { + const tag = getEventSelectedTag(e); + if (tag) handleDragEnd.call(tag, e); +} + +function handleDelegatedDragOver(e) { + const tag = getEventSelectedTag(e); + if (tag) { + handleDragOver.call(tag, e); + } else { + handleSelectedTagsContainerDragOver(e); + } +} + +function handleDelegatedDrop(e) { + const tag = getEventSelectedTag(e); + if (tag) { + handleDrop.call(tag, e); + } else { + handleSelectedTagsContainerDrop(e); + } +} + +function handleDelegatedDragEnter(e) { + const tag = getEventSelectedTag(e); + if (tag) handleDragEnter.call(tag, e); +} + +function handleDelegatedDragLeave(e) { + const tag = getEventSelectedTag(e); + if (tag) handleDragLeave.call(tag, e); +} + +function handleDelegatedTouchStart(e) { + const tag = getEventSelectedTag(e); + if (tag) handleTouchStart.call(tag, e); +} + +function getSelectedDropTarget(clientX, clientY) { + const container = document.getElementById('selected-tags'); + if (!container || !draggedElement) return null; + + const pointElement = document.elementFromPoint(clientX, clientY); + const directTag = pointElement ? pointElement.closest('.selected-tag') : null; + if (directTag === draggedElement) return null; + if (directTag && container.contains(directTag) && !directTag.dataset.dragGhost) { + return { tag: directTag, direct: true }; + } + + const containerRect = container.getBoundingClientRect(); + const insideContainer = clientX >= containerRect.left && clientX <= containerRect.right && + clientY >= containerRect.top && clientY <= containerRect.bottom; + if (!insideContainer) return null; + + const tags = getSelectedTagElements().filter(tag => tag !== draggedElement); + if (tags.length === 0) return { tag: null, direct: false }; + + let closestTag = tags[0]; + let closestDistance = Infinity; tags.forEach(tag => { - // 鼠标拖放事件 - tag.addEventListener('dragstart', handleDragStart); - tag.addEventListener('dragend', handleDragEnd); - tag.addEventListener('dragover', handleDragOver); - tag.addEventListener('drop', handleDrop); - tag.addEventListener('dragenter', handleDragEnter); - tag.addEventListener('dragleave', handleDragLeave); - - // 触摸拖放事件 - tag.addEventListener('touchstart', handleTouchStart); - tag.addEventListener('touchmove', handleTouchMove); - tag.addEventListener('touchend', handleTouchEnd); - - // 确保删除按钮可以被点击 - const removeBtn = tag.querySelector('.remove-btn'); - if (removeBtn) { - // 阻止删除按钮上的触摸事件冒泡到父元素 - removeBtn.addEventListener('touchstart', function(e) { - e.stopPropagation(); - }); - - removeBtn.addEventListener('touchend', function(e) { - e.stopPropagation(); - }); + const rect = tag.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + const centerY = rect.top + rect.height / 2; + const distance = Math.pow(clientX - centerX, 2) + Math.pow(clientY - centerY, 2); + if (distance < closestDistance) { + closestDistance = distance; + closestTag = tag; } }); + + return { tag: closestTag, direct: false }; +} + +function moveSelectedTagToPoint(clientX, clientY) { + const container = document.getElementById('selected-tags'); + const dropTarget = getSelectedDropTarget(clientX, clientY); + if (!container || !draggedElement || !dropTarget) return false; + + const targetTag = dropTarget.tag; + if (!targetTag) { + container.appendChild(draggedElement); + return true; + } + + const allTags = getSelectedTagElements(); + const draggedIndex = allTags.indexOf(draggedElement); + const targetIndex = allTags.indexOf(targetTag); + + if (dropTarget.direct) { + container.insertBefore(draggedElement, draggedIndex < targetIndex ? targetTag.nextSibling : targetTag); + return true; + } + + const targetRect = targetTag.getBoundingClientRect(); + const onSameRow = clientY >= targetRect.top && clientY <= targetRect.bottom; + const insertBefore = onSameRow + ? clientX < targetRect.left + targetRect.width / 2 + : clientY < targetRect.top + targetRect.height / 2; + container.insertBefore(draggedElement, insertBefore ? targetTag : targetTag.nextSibling); + return true; +} + +function handleSelectedTagsContainerDragOver(e) { + if (!draggedElement) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; +} + +function handleSelectedTagsContainerDrop(e) { + const targetTag = e.target && e.target.closest ? e.target.closest('.selected-tag') : null; + if (!draggedElement || targetTag) return; + e.preventDefault(); + e.stopPropagation(); + moveSelectedTagToPoint(e.clientX, e.clientY); } function handleDragStart(e) { + cleanupSelectedTagsTouchDrag(); draggedElement = this; this.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; @@ -1988,9 +2132,10 @@ function handleDragStart(e) { function handleDragEnd(e) { this.classList.remove('dragging'); - document.querySelectorAll('.selected-tag').forEach(tag => { + getSelectedTagElements().forEach(tag => { tag.classList.remove('drag-over'); }); + draggedElement = null; } function handleDragOver(e) { @@ -2018,7 +2163,7 @@ function handleDrop(e) { if (draggedElement !== this) { const container = document.getElementById('selected-tags'); - const allTags = Array.from(container.querySelectorAll('.selected-tag')); + const allTags = getSelectedTagElements(); const draggedIndex = allTags.indexOf(draggedElement); const targetIndex = allTags.indexOf(this); @@ -2036,7 +2181,7 @@ function handleDrop(e) { // 触摸拖动事件处理 function handleTouchStart(e) { // 检查点击的是否是删除按钮 - if (e.target.classList.contains('remove-btn')) { + if (e.target && e.target.closest && e.target.closest('.remove-btn')) { // 如果点击的是删除按钮,则不执行拖动操作 return; } @@ -2044,10 +2189,8 @@ function handleTouchStart(e) { // 防止默认的触摸行为 e.preventDefault(); - // 获取触摸点 - const touch = e.touches[0]; - // 模拟拖动开始 + cleanupSelectedTagsTouchDrag(); draggedElement = this; this.classList.add('dragging'); touchDragging = true; @@ -2060,6 +2203,7 @@ function handleTouchStart(e) { // 添加触摸移动和结束事件监听器到文档 document.addEventListener('touchmove', handleTouchMove, { passive: false }); document.addEventListener('touchend', handleTouchEnd, { passive: false }); + document.addEventListener('touchcancel', handleTouchCancel, { passive: false }); } function handleTouchMove(e) { @@ -2068,111 +2212,51 @@ function handleTouchMove(e) { // 防止默认的触摸行为 e.preventDefault(); - // 通过 requestAnimationFrame 批量调度 DOM 更新,避免频繁重排 - if (window.requestAnimationFrame) { - window.requestAnimationFrame(() => { - // 获取触摸点位置 - const touch = e.touches[0]; - - // 获取拖动元素的尺寸 - const elementRect = draggedElement.getBoundingClientRect(); - - // 创建一个临时的拖动元素,而不是移动原始元素 - if (!document.getElementById('touch-drag-ghost')) { - const ghostElement = draggedElement.cloneNode(true); - ghostElement.id = 'touch-drag-ghost'; - ghostElement.style.position = 'fixed'; // 使用 fixed 而不是 absolute - ghostElement.style.left = '0'; - ghostElement.style.top = '0'; - ghostElement.style.pointerEvents = 'none'; // 防止干扰触摸事件 - ghostElement.style.zIndex = '9999'; - ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; - ghostElement.style.opacity = '0.8'; - ghostElement.style.boxSizing = 'border-box'; // 确保尺寸计算正确 - ghostElement.style.width = elementRect.width + 'px'; // 固定宽度 - ghostElement.style.height = elementRect.height + 'px'; // 固定高度 - document.body.appendChild(ghostElement); - } else { - const ghostElement = document.getElementById('touch-drag-ghost'); - ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; - } - - // 检查与其他元素的碰撞 - const container = document.getElementById('selected-tags'); - const tags = Array.from(container.querySelectorAll('.selected-tag')).filter(tag => tag !== draggedElement); - let targetElement = null; - - for (const tag of tags) { - const rect = tag.getBoundingClientRect(); - if (touch.clientX >= rect.left && touch.clientX <= rect.right && - touch.clientY >= rect.top && touch.clientY <= rect.bottom) { - targetElement = tag; - break; - } - } - - // 高亮目标元素 - document.querySelectorAll('.selected-tag').forEach(tag => { - if (tag !== draggedElement) { - tag.classList.remove('drag-over'); - } - }); - - if (targetElement) { - targetElement.classList.add('drag-over'); - } - }); - } else { - // 降级处理,如果不支持 requestAnimationFrame - const touch = e.touches[0]; - - // 获取拖动元素的尺寸 + const touch = e.touches[0]; + if (!touch) return; + const clientX = touch.clientX; + const clientY = touch.clientY; + + if (touchDragFrame !== null && window.cancelAnimationFrame) { + window.cancelAnimationFrame(touchDragFrame); + } + + const updatePreview = () => { + touchDragFrame = null; + if (!touchDragging || !draggedElement) return; + const elementRect = draggedElement.getBoundingClientRect(); - - // 创建一个临时的拖动元素,而不是移动原始元素 - if (!document.getElementById('touch-drag-ghost')) { - const ghostElement = draggedElement.cloneNode(true); + let ghostElement = document.getElementById('touch-drag-ghost'); + if (!ghostElement) { + ghostElement = draggedElement.cloneNode(true); ghostElement.id = 'touch-drag-ghost'; - ghostElement.style.position = 'fixed'; // 使用 fixed 而不是 absolute + ghostElement.dataset.dragGhost = 'true'; + ghostElement.setAttribute('aria-hidden', 'true'); + ghostElement.removeAttribute('draggable'); + ghostElement.style.position = 'fixed'; ghostElement.style.left = '0'; ghostElement.style.top = '0'; - ghostElement.style.pointerEvents = 'none'; // 防止干扰触摸事件 + ghostElement.style.pointerEvents = 'none'; ghostElement.style.zIndex = '9999'; - ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; ghostElement.style.opacity = '0.8'; - ghostElement.style.boxSizing = 'border-box'; // 确保尺寸计算正确 - ghostElement.style.width = elementRect.width + 'px'; // 固定宽度 - ghostElement.style.height = elementRect.height + 'px'; // 固定高度 + ghostElement.style.boxSizing = 'border-box'; + ghostElement.style.width = elementRect.width + 'px'; + ghostElement.style.height = elementRect.height + 'px'; document.body.appendChild(ghostElement); - } else { - const ghostElement = document.getElementById('touch-drag-ghost'); - ghostElement.style.transform = 'translate(' + (touch.clientX - (elementRect.width / 2)) + 'px, ' + (touch.clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; } - - // 检查与其他元素的碰撞 - const container = document.getElementById('selected-tags'); - const tags = Array.from(container.querySelectorAll('.selected-tag')).filter(tag => tag !== draggedElement); - let targetElement = null; - - for (const tag of tags) { - const rect = tag.getBoundingClientRect(); - if (touch.clientX >= rect.left && touch.clientX <= rect.right && - touch.clientY >= rect.top && touch.clientY <= rect.bottom) { - targetElement = tag; - break; - } - } - - // 高亮目标元素 - document.querySelectorAll('.selected-tag').forEach(tag => { - if (tag !== draggedElement) { - tag.classList.remove('drag-over'); - } + ghostElement.style.transform = 'translate(' + (clientX - (elementRect.width / 2)) + 'px, ' + (clientY - (elementRect.height / 2)) + 'px) rotate(5deg)'; + + const dropTarget = getSelectedDropTarget(clientX, clientY); + getSelectedTagElements().forEach(tag => { + if (tag !== draggedElement) tag.classList.remove('drag-over'); }); - - if (targetElement) { - targetElement.classList.add('drag-over'); - } + if (dropTarget && dropTarget.tag) dropTarget.tag.classList.add('drag-over'); + }; + + if (window.requestAnimationFrame) { + touchDragFrame = window.requestAnimationFrame(updatePreview); + } else { + updatePreview(); } } @@ -2182,52 +2266,43 @@ function handleTouchEnd(e) { // 防止默认的触摸行为 e.preventDefault(); - // 移除临时拖动元素 - const ghostElement = document.getElementById('touch-drag-ghost'); - if (ghostElement) { - document.body.removeChild(ghostElement); - } - - // 找到目标元素(如果有) - const touch = e.changedTouches[0]; - const targetElement = document.elementFromPoint(touch.clientX, touch.clientY); - - const container = document.getElementById('selected-tags'); - const targetTag = targetElement.closest('.selected-tag'); - - // 如果目标是另一个标签,执行交换 - if (targetTag && targetTag !== draggedElement && container.contains(targetTag)) { - const allTags = Array.from(container.querySelectorAll('.selected-tag')); - const draggedIndex = allTags.indexOf(draggedElement); - const targetIndex = allTags.indexOf(targetTag); + const touch = e.changedTouches && e.changedTouches[0]; + if (touch) moveSelectedTagToPoint(touch.clientX, touch.clientY); + cleanupSelectedTagsTouchDrag(); +} - if (draggedIndex < targetIndex) { - targetTag.parentNode.insertBefore(draggedElement, targetTag.nextSibling); - } else { - targetTag.parentNode.insertBefore(draggedElement, targetTag); - } +function handleTouchCancel(e) { + if (e && e.cancelable) e.preventDefault(); + cleanupSelectedTagsTouchDrag(); +} + +function cleanupSelectedTagsTouchDrag() { + if (touchDragFrame !== null && window.cancelAnimationFrame) { + window.cancelAnimationFrame(touchDragFrame); } - - // 重置元素样式 - draggedElement.style.transform = ''; - draggedElement.style.opacity = ''; - draggedElement.style.zIndex = ''; - - // 移除拖动类 - draggedElement.classList.remove('dragging'); - document.querySelectorAll('.selected-tag').forEach(tag => { - tag.classList.remove('drag-over'); - }); - - // 重置变量 + touchDragFrame = null; + + const ghostElement = document.getElementById('touch-drag-ghost'); + if (ghostElement) ghostElement.remove(); + + if (draggedElement && touchDragging) { + draggedElement.style.transform = ''; + draggedElement.style.opacity = ''; + draggedElement.style.zIndex = ''; + draggedElement.classList.remove('dragging'); + } + getSelectedTagElements().forEach(tag => tag.classList.remove('drag-over')); + touchDragging = false; - draggedElement = null; - - // 移除事件监听器 + if (draggedElement && !draggedElement.classList.contains('dragging')) draggedElement = null; + document.removeEventListener('touchmove', handleTouchMove); document.removeEventListener('touchend', handleTouchEnd); + document.removeEventListener('touchcancel', handleTouchCancel); } +window.addEventListener('blur', cleanupSelectedTagsTouchDrag); + // 显示加载遮罩 function showLoading(text, detail) { document.getElementById('loading-text').textContent = text; @@ -2500,8 +2575,9 @@ document.getElementById('env-form').addEventListener('submit', async function(e) confirmMergeGroup(); } - const selectedTags = Array.from(document.querySelectorAll('.selected-tag')) - .map(el => el.dataset.value); + const selectedTags = [...new Set( + getSelectedTagElements().map(el => el.dataset.value).filter(Boolean) + )]; value = selectedTags.join(','); const options = Array.from(document.querySelectorAll('.available-tag')).map(el => el.dataset.value); itemData = { key, value, description, type, options }; diff --git a/danmu_api/ui/template.js b/danmu_api/ui/template.js index 96d40d8..7744351 100644 --- a/danmu_api/ui/template.js +++ b/danmu_api/ui/template.js @@ -142,11 +142,11 @@ export const HTML_TEMPLATE = /* html */ `

接口调试

- - + +
-
+
@@ -172,7 +172,7 @@ export const HTML_TEMPLATE = /* html */ `
-
+
diff --git a/danmu_api/utils/auto-match-mapping-util.js b/danmu_api/utils/auto-match-mapping-util.js new file mode 100644 index 0000000..ad35f10 --- /dev/null +++ b/danmu_api/utils/auto-match-mapping-util.js @@ -0,0 +1,205 @@ +function normalizeRuleTitle(value) { + return String(value || '') + .normalize('NFKC') + .replace(/[^\p{L}\p{N}]/gu, '') + .toLowerCase(); +} + +function parseEpisodeSide(value, { allowPlatform = false } = {}) { + let text = String(value || '').trim(); + let platform = ''; + + if (allowPlatform) { + const platformMatch = text.match(/\s+@([a-zA-Z0-9_-]+)\s*$/); + if (platformMatch) { + platform = platformMatch[1].toLowerCase(); + text = text.slice(0, platformMatch.index).trim(); + } + } + + const match = text.match(/^(.+?)\s+S(\d+)E(\d+)(?:~E?(\d+))?\s*$/i); + if (!match) return null; + + const title = match[1].trim(); + const season = Number(match[2]); + const startEpisode = Number(match[3]); + const endEpisode = match[4] === undefined ? null : Number(match[4]); + if (!title || season < 1 || startEpisode < 1 || (endEpisode !== null && endEpisode < startEpisode)) return null; + + return { title, season, startEpisode, endEpisode, platform }; +} + +function parseTargetTitle(value) { + const displayTitle = String(value || '').trim(); + const yearMatch = displayTitle.match(/[((]((?:19|20)\d{2})[))]/); + const typeMatches = [...displayTitle.matchAll(/【([^】]+)】/g)]; + const mediaType = typeMatches.length > 0 ? typeMatches[typeMatches.length - 1][1].trim() : ''; + const title = displayTitle + .replace(/[((](?:19|20)\d{2}[))]/g, '') + .replace(/【[^】]+】/g, '') + .trim(); + + return { + title, + displayTitle, + year: yearMatch ? Number(yearMatch[1]) : null, + mediaType + }; +} + +/** + * Parse AUTO_MATCH_MAPPING_TABLE into validated, declaration-ordered rules. + */ +export function parseAutoMatchMappingRules(value, allowedPlatforms = []) { + const rules = []; + const warnings = []; + const allowed = new Set((allowedPlatforms || []).map(item => String(item).toLowerCase())); + + for (const [index, rawRule] of String(value || '').split(';').entries()) { + const text = rawRule.trim(); + if (!text) continue; + + const arrowIndex = text.indexOf('->'); + if (arrowIndex === -1 || text.indexOf('->', arrowIndex + 2) !== -1) { + warnings.push(`规则 ${index + 1} 缺少唯一的 -> 分隔符: ${text}`); + continue; + } + + const source = parseEpisodeSide(text.slice(0, arrowIndex)); + const targetSide = parseEpisodeSide(text.slice(arrowIndex + 2), { allowPlatform: true }); + if (!source || !targetSide) { + warnings.push(`规则 ${index + 1} 的季集格式无效: ${text}`); + continue; + } + + const bounded = source.endEpisode !== null; + if (bounded !== (targetSide.endEpisode !== null)) { + warnings.push(`规则 ${index + 1} 的源和目标必须同时声明范围: ${text}`); + continue; + } + if (bounded && source.endEpisode - source.startEpisode !== targetSide.endEpisode - targetSide.startEpisode) { + warnings.push(`规则 ${index + 1} 的源和目标范围长度不一致: ${text}`); + continue; + } + if (targetSide.platform && allowed.size > 0 && !allowed.has(targetSide.platform)) { + warnings.push(`规则 ${index + 1} 使用了不支持的平台 ${targetSide.platform}: ${text}`); + continue; + } + + const targetTitle = parseTargetTitle(targetSide.title); + if (!targetTitle.title) { + warnings.push(`规则 ${index + 1} 的目标标题为空: ${text}`); + continue; + } + + rules.push({ + order: index, + raw: text, + bounded, + sourceTitle: source.title, + sourceTitleKey: normalizeRuleTitle(source.title), + sourceSeason: source.season, + sourceStartEpisode: source.startEpisode, + sourceEndEpisode: source.endEpisode, + targetTitle: targetTitle.title, + targetDisplayTitle: targetTitle.displayTitle, + targetYear: targetTitle.year, + targetType: targetTitle.mediaType, + targetSeason: targetSide.season, + targetStartEpisode: targetSide.startEpisode, + targetEndEpisode: targetSide.endEpisode, + targetPlatform: targetSide.platform + }); + } + + return { rules, warnings }; +} + +export function resolveAutoMatchMapping(rules, { title, season, episode }) { + const titleKey = normalizeRuleTitle(title); + const seasonNumber = Number(season); + const episodeNumber = Number(episode); + if (!titleKey || !Number.isInteger(seasonNumber) || !Number.isInteger(episodeNumber)) return null; + + const matches = (Array.isArray(rules) ? rules : []).filter(rule => { + if (rule.sourceTitleKey !== titleKey || rule.sourceSeason !== seasonNumber) return false; + if (episodeNumber < rule.sourceStartEpisode) return false; + return rule.sourceEndEpisode === null || episodeNumber <= rule.sourceEndEpisode; + }); + matches.sort((left, right) => Number(right.bounded) - Number(left.bounded) || left.order - right.order); + + const rule = matches[0]; + if (!rule) return null; + return { + ...rule, + targetEpisode: rule.targetStartEpisode + episodeNumber - rule.sourceStartEpisode + }; +} + +function normalizeMediaType(value) { + const type = normalizeRuleTitle(value); + if (!type) return ''; + if (/(日番|番剧|动漫|动画|anime|animation)/i.test(type)) return 'anime'; + if (/(电影|剧场|movie|film)/i.test(type)) return 'movie'; + if (/(电视剧|电视|tvseries|series|drama)/i.test(type)) return 'series'; + if (/(综艺|variety)/i.test(type)) return 'variety'; + if (/ova/i.test(type)) return 'ova'; + return type; +} + +function stripCandidateTitleMetadata(value) { + return String(value || '') + .replace(/\s*from\s+.+$/i, '') + .replace(/[((](?:19|20)\d{2}[))]/g, '') + .replace(/【[^】]+】/g, '') + .trim(); +} + +export function candidateMatchesMappingTitle(anime, mapping) { + if (!anime || !mapping?.targetTitle) return false; + const expected = normalizeRuleTitle(mapping.targetTitle); + const candidateTitles = [anime.animeTitle, ...(Array.isArray(anime.aliases) ? anime.aliases : [])].filter(Boolean); + + return candidateTitles.some(value => { + const candidate = normalizeRuleTitle(stripCandidateTitleMetadata(value)); + if (candidate === expected) return true; + if (!candidate.startsWith(expected)) return false; + const suffix = candidate.slice(expected.length); + return /^(?:第[一二三四五六七八九十百\d]+季|season\d+|s\d+)$/i.test(suffix); + }); +} + +export function candidateMatchesMappingQualifiers(anime, mapping) { + if (!anime || !mapping) return false; + const candidateTitles = [anime.animeTitle, ...(Array.isArray(anime.aliases) ? anime.aliases : [])].filter(Boolean); + + if (mapping.targetYear) { + const years = candidateTitles + .map(title => String(title).match(/(?:19|20)\d{2}/)?.[0]) + .filter(Boolean) + .map(Number); + const startYear = String(anime.startDate || '').match(/^(?:19|20)\d{2}/)?.[0]; + if (startYear) years.push(Number(startYear)); + if (!years.includes(mapping.targetYear)) return false; + } + + if (mapping.targetType) { + const expectedType = normalizeMediaType(mapping.targetType); + const candidateTypes = [anime.type, anime.typeDescription]; + for (const title of candidateTitles) { + candidateTypes.push(...[...String(title).matchAll(/【([^】]+)】/g)].map(match => match[1])); + } + if (!candidateTypes.some(type => normalizeMediaType(type) === expectedType)) return false; + } + + return true; +} + +export function filterMappingQualifierCandidates(animes, mapping) { + if (!mapping?.targetYear && !mapping?.targetType) return []; + return (Array.isArray(animes) ? animes : []).filter(anime => candidateMatchesMappingQualifiers(anime, mapping)); +} + +export function filterMappingTargetCandidates(animes, mapping) { + return (Array.isArray(animes) ? animes : []).filter(anime => candidateMatchesMappingTitle(anime, mapping)); +} diff --git a/danmu_api/utils/cache-util.js b/danmu_api/utils/cache-util.js index d66cd26..9f2e813 100644 --- a/danmu_api/utils/cache-util.js +++ b/danmu_api/utils/cache-util.js @@ -2,7 +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"; +import { loadFavorites, resolveFavoriteForSearchKeyword, saveFavorites } from "./favorite-util.js"; let fs, path; // ===================== @@ -283,7 +283,7 @@ export function resolveEpisodeContextById(id, detailStore = null) { } // 检查搜索缓存是否有效(未过期) export function isSearchCacheValid(keyword) { - if (resolveFavoriteForKeyword(keyword)) { + if (resolveFavoriteForSearchKeyword(keyword)) { return true; } @@ -308,7 +308,7 @@ export function isSearchCacheValid(keyword) { // 获取搜索缓存 export function getSearchCache(keyword, detailsMap = null) { // 收藏剧集永久缓存优先命中(无 TTL、无数量上限) - const favorite = resolveFavoriteForKeyword(keyword); + const favorite = resolveFavoriteForSearchKeyword(keyword); if (favorite) { const favoriteEntry = favorite.entry; log("info", `[cache] Using favorite cache for "${keyword}"`); @@ -621,12 +621,13 @@ export function storeAnimeIdsToMap(curAnimes, key) { uniqueAnimeIds.add(anime.animeId); } - // 保存旧的 prefer/source/offsets(兼容旧结构) + // 保存旧的 prefer/source/offsets/explicitBySeason(兼容旧结构) const oldPrefer = oldValue?.prefer; const oldSource = oldValue?.source; const oldPreferBySeason = oldValue?.preferBySeason; const oldSourceBySeason = oldValue?.sourceBySeason; const oldOffsets = oldValue?.offsets; + const oldExplicitBySeason = oldValue?.explicitBySeason; const preferBySeason = oldPreferBySeason ? { ...oldPreferBySeason } : {}; const sourceBySeason = oldSourceBySeason ? { ...oldSourceBySeason } : {}; @@ -648,7 +649,8 @@ export function storeAnimeIdsToMap(curAnimes, key) { animeIds: [...uniqueAnimeIds], ...(Object.keys(preferBySeason).length > 0 && { preferBySeason }), ...(Object.keys(sourceBySeason).length > 0 && { sourceBySeason }), - ...(oldOffsets !== undefined && { offsets: oldOffsets }) + ...(oldOffsets !== undefined && { offsets: oldOffsets }), + ...(oldExplicitBySeason !== undefined && { explicitBySeason: { ...oldExplicitBySeason } }) }); // 检查是否超过 MAX_LAST_SELECT_MAP,超过则删除最早的 @@ -678,6 +680,8 @@ export function setPreferByAnimeId(animeId, source, season = null, offset = null value.sourceBySeason = value.sourceBySeason || {}; value.preferBySeason[seasonKey] = animeId; value.sourceBySeason[seasonKey] = source; + value.explicitBySeason = value.explicitBySeason || {}; + value.explicitBySeason[seasonKey] = true; if (season !== null && offset !== null) { value.offsets = value.offsets || {}; value.offsets[seasonKey] = offset; @@ -689,6 +693,53 @@ export function setPreferByAnimeId(animeId, source, season = null, offset = null return null; // 如果没有找到匹配的 key,返回 null } +export function setPreferForTitle(title, animeId, source, season = null, offset = null) { + const key = String(title || '').trim(); + if (!key || animeId === null || animeId === undefined) return null; + + const oldValue = globals.lastSelectMap.get(key) || {}; + const animeIds = new Set(Array.isArray(oldValue.animeIds) ? oldValue.animeIds : []); + animeIds.add(animeId); + const seasonKey = season === null ? 'default' : String(season); + const preferBySeason = { ...(oldValue.preferBySeason || {}), [seasonKey]: animeId }; + const sourceBySeason = { ...(oldValue.sourceBySeason || {}), [seasonKey]: source }; + const explicitBySeason = { ...(oldValue.explicitBySeason || {}), [seasonKey]: true }; + const offsets = { ...(oldValue.offsets || {}) }; + if (season !== null && offset !== null) offsets[seasonKey] = offset; + + if (globals.lastSelectMap.has(key)) globals.lastSelectMap.delete(key); + globals.lastSelectMap.set(key, { + animeIds: [...animeIds], + preferBySeason, + sourceBySeason, + explicitBySeason, + ...(Object.keys(offsets).length > 0 && { offsets }) + }); + + if (globals.lastSelectMap.size > globals.MAX_LAST_SELECT_MAP) { + globals.lastSelectMap.delete(globals.lastSelectMap.keys().next().value); + } + return key; +} + +export function hasSeasonSpecificPreference(title, season) { + if (season === null || season === undefined) return false; + const value = globals.lastSelectMap.get(String(title || '').trim()); + if (!value) return false; + const seasonKey = String(season); + return value.explicitBySeason?.[seasonKey] === true; +} + +export function hasLegacySeasonPreference(title, season) { + if (season === null || season === undefined) return false; + const value = globals.lastSelectMap.get(String(title || '').trim()); + if (!value) return false; + const seasonKey = String(season); + const hasOwn = object => object && Object.prototype.hasOwnProperty.call(object, seasonKey); + const hasRecord = hasOwn(value.preferBySeason) || hasOwn(value.sourceBySeason) || hasOwn(value.offsets); + return Boolean(hasRecord && value.explicitBySeason?.[seasonKey] !== true); +} + // 通过 title 查询优选 animeId(按 season 维度) export function getPreferAnimeId(title, season = null) { const value = globals.lastSelectMap.get(title); diff --git a/danmu_api/utils/favorite-util.js b/danmu_api/utils/favorite-util.js index 8072420..a3288d9 100644 --- a/danmu_api/utils/favorite-util.js +++ b/danmu_api/utils/favorite-util.js @@ -64,6 +64,27 @@ function getEntryTitles(keyword, entry) { return [...titles].filter(title => title.length >= 2); } +// 搜索只能用完整标题命中收藏,避免短关键词误复用更长标题的收藏结果。 +// 季度缓存键和收藏结果中的完整标题仍视为同一部剧。 +export function resolveFavoriteForSearchKeyword(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) return null; + + for (const [favoriteKeyword, entry] of globals.favoriteCache.entries()) { + if (stripSeasonSuffix(favoriteKeyword) === queryTitle || getEntryTitles(favoriteKeyword, entry).includes(queryTitle)) { + return { keyword: favoriteKeyword, entry }; + } + } + return null; +} + // 先精确匹配关键词,再按收藏键和收藏结果中的剧名做包含兜底。 export function resolveFavoriteForKeyword(keyword) { const key = String(keyword || '').trim(); diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 60dc258..8a95089 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -7,6 +7,7 @@ import assert from 'node:assert'; import { handleRequest } from './worker.js'; import { extractTitleSeasonEpisode, getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, buildSearchAnimeUrl } from "./apis/dandan-api.js"; import { handleFavoriteRefresh } from './apis/favorite-api.js'; +import { handleClearCache } from './apis/system-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"; @@ -38,11 +39,13 @@ 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, getSearchCache, isSearchCacheValid, setSearchCache } from "./utils/cache-util.js"; +import { addAnime, addEpisode, getSearchCache, hasSeasonSpecificPreference, isSearchCacheValid, setSearchCache } from "./utils/cache-util.js"; import { addFavorite, listFavorites, loadFavorites, removeFavorite, resolveFavoriteForKeyword, saveFavorites } from './utils/favorite-util.js'; +import { candidateMatchesMappingQualifiers, candidateMatchesMappingTitle, parseAutoMatchMappingRules, resolveAutoMatchMapping } from './utils/auto-match-mapping-util.js'; import { HTML_TEMPLATE } from './ui/template.js'; import { apitestJsContent } from './ui/js/apitest.js'; import { systemSettingsJsContent } from './ui/js/systemsettings.js'; +import { previewJsContent } from './ui/js/preview.js'; import { convertToAsciiSum } from "./utils/codec-util.js"; import { convertToDanmakuJson, handleDanmusLike } from "./utils/danmu-util.js"; import { Segment, SegmentListResponse } from "./models/dandan-model.js" @@ -292,7 +295,259 @@ test('worker.js API endpoints', async (t) => { ({title, season, episode} = await extractTitleSeasonEpisode("宇宙Marry Me? S02E08")); assert(title === "宇宙Marry Me?" && season == 2 && episode == 8, `Expected title === "宇宙Marry Me?" && season == 2 && episode == 8, but got ${title} ${season} ${episode}`); }); - + + await t.test('auto match mapping table', async t => { + await t.test('parses and resolves open, bounded, qualified, and platform rules', () => { + const { rules, warnings } = parseAutoMatchMappingRules([ + '永生 S05E02->永生 S01E58', + '永生 S05E02~03->永生 S01E58~59', + '海贼王 S2E1->航海王(1999)【动漫】 S1E62', + '航海王 S1E1->航海王 S1E1 @qiyi' + ].join(';'), Globals.envs.allowedPlatforms); + + assert.deepEqual(warnings, []); + assert.equal(rules.length, 4); + assert.equal(resolveAutoMatchMapping(rules, { title: '永生', season: 5, episode: 2 }).bounded, true); + assert.equal(resolveAutoMatchMapping(rules, { title: '永生', season: 5, episode: 3 }).targetEpisode, 59); + assert.equal(resolveAutoMatchMapping(rules, { title: '永生', season: 5, episode: 4 }).targetEpisode, 60); + assert.equal(resolveAutoMatchMapping(rules, { title: '永生', season: 6, episode: 1 }), null); + + const qualified = rules[2]; + assert.equal(qualified.targetTitle, '航海王'); + assert.equal(qualified.targetYear, 1999); + assert.equal(qualified.targetType, '动漫'); + assert.equal(rules[3].targetPlatform, 'qiyi'); + assert.equal(candidateMatchesMappingQualifiers({ + animeTitle: '航海王(1999)【动漫】from tencent', + typeDescription: '动漫', + startDate: '1999-10-20T00:00:00.000Z' + }, qualified), true); + assert.equal(candidateMatchesMappingQualifiers({ + animeTitle: '航海王(2000)【动漫】from tencent', + typeDescription: '动漫', + startDate: '2000-01-01T00:00:00.000Z' + }, qualified), false); + assert.equal(candidateMatchesMappingTitle({ animeTitle: '航海王 第二季(1999)【动漫】from tencent' }, qualified), true); + assert.equal(candidateMatchesMappingTitle({ animeTitle: '海贼王(1999)【动漫】from tencent' }, qualified), false); + + const boundedOnly = parseAutoMatchMappingRules('永生 S05E02~03->永生 S01E58~59').rules; + assert.equal(resolveAutoMatchMapping(boundedOnly, { title: '永生', season: 5, episode: 3 }).targetEpisode, 59); + assert.equal(resolveAutoMatchMapping(boundedOnly, { title: '永生', season: 5, episode: 4 }), null); + + const narutoRule = parseAutoMatchMappingRules('火影忍者 S01E57->火影忍者 疾风传(2007)【日番】 S01E59').rules[0]; + assert.equal(resolveAutoMatchMapping([narutoRule], { title: '火影忍者', season: 1, episode: 57 }).targetEpisode, 59); + assert.equal(resolveAutoMatchMapping([narutoRule], { title: '火影忍者', season: 1, episode: 58 }).targetEpisode, 60); + assert.equal(candidateMatchesMappingQualifiers({ + animeTitle: '火影忍者疾风传(2007)【动漫】from 360', + typeDescription: '动漫', + startDate: '2007-02-15T00:00:00.000Z' + }, narutoRule), true); + }); + + await t.test('rejects invalid ranges and keeps declaration order for equal specificity', () => { + const parsed = parseAutoMatchMappingRules([ + '测试 S01E02~04->测试 S01E10~11', + '测试 S01E02~03->测试 S01E20~21', + '测试 S01E02~03->测试 S01E30~31' + ].join(';')); + assert.equal(parsed.warnings.length, 1); + assert.equal(parsed.rules.length, 2); + assert.equal(resolveAutoMatchMapping(parsed.rules, { title: '测试', season: 1, episode: 2 }).targetEpisode, 20); + }); + + await t.test('maps match input, honors qualifiers and manual season preference, then falls back to original', async () => { + const originalSearch = TencentSource.prototype.search; + const originalHandleAnimes = TencentSource.prototype.handleAnimes; + const originalGetComments = TencentSource.prototype.getComments; + const originalAiAsk = AIClient.prototype.ask; + const originalOrder = Globals.envs.sourceOrderArr; + const originalAiValid = Globals.aiValid; + let searchKeywords = []; + let aiMatchInput = null; + let scenario = 'open'; + + TencentSource.prototype.search = async keyword => { + searchKeywords.push(keyword); + return [{ keyword }]; + }; + TencentSource.prototype.handleAnimes = async (_source, title, results, details) => { + const add = anime => { + results.push(anime); + details.set(String(anime.animeId), anime); + }; + if (scenario === 'fallback' && title === '缺失目标') return; + if (scenario === 'qualified' && title === '航海王') { + add(createFavoriteAnime('无关动漫(1999)【动漫】from tencent', 70, 930000)); + add(createFavoriteAnime('航海王(2000)【动漫】from tencent', 70, 930001)); + add(createFavoriteAnime('航海王(1999)【动漫】from tencent', 70, 930002)); + return; + } + if (scenario === 'platform') { + const qqAnime = createFavoriteAnime(title, 2, 930004); + const qiyiAnime = createFavoriteAnime(title, 2, 930005); + qiyiAnime.source = 'iqiyi'; + qiyiAnime.links.forEach(link => { link.title = link.title.replace('【qq】', '【qiyi】'); }); + add(qqAnime); + add(qiyiAnime); + return; + } + if (scenario === 'naruto') { + if (title === '火影忍者 疾风传') { + add(createFavoriteAnime('火影忍者疾风传(2007)【动漫】from 360', 70, 930006)); + } else { + add(createFavoriteAnime('火影忍者(2002)【动漫】from 360', 70, 930007)); + } + return; + } + add(createFavoriteAnime(title, 70, 930003)); + }; + TencentSource.prototype.getComments = async () => [{ p: '1,1,16777215,test', m: 'mapping-test' }]; + Globals.envs.sourceOrderArr = ['tencent']; + + const runMatch = async (env, fileName, useAi = false) => { + resetFavoriteState(env); + Globals.envs.sourceOrderArr = ['tencent']; + Globals.aiValid = useAi; + const request = new Request('http://localhost/api/v2/match', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fileName }) + }); + return parseResponse(await matchAnime(new URL(request.url), request, '127.0.0.1')); + }; + + try { + searchKeywords = []; + scenario = 'open'; + let body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '永生 S05E02->永生 S01E58' }, '永生 S05E03'); + assert.equal(body.matches[0].episodeId, 9300030 + 59); + assert.deepEqual(searchKeywords, ['永生']); + await getComment(`/api/v2/comment/${body.matches[0].episodeId}`, 'json', false, '127.0.0.1'); + assert.equal(hasSeasonSpecificPreference('永生', 5), false); + await getComment(`/api/v2/comment/${9300030 + 60}`, 'json', false, '127.0.0.1'); + assert.equal(hasSeasonSpecificPreference('永生', 5), true); + assert.match(Globals.lastSelectMap.get('永生').offsets['5'], /^3:/); + + body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '永生 S05E02->永生 S01E58' }, '永生 S06E01'); + assert.equal(body.matches[0].episodeId, 9300030 + 1); + await getComment(`/api/v2/comment/${body.matches[0].episodeId}`, 'json', false, '127.0.0.1'); + assert.equal(hasSeasonSpecificPreference('永生', 6), false); + + resetFavoriteState({ AUTO_MATCH_MAPPING_TABLE: '永生 S05E02->永生 S01E58' }); + Globals.envs.sourceOrderArr = ['tencent']; + Globals.lastSelectMap.set('永生', { + animeIds: [930003], + preferBySeason: { default: 930003 }, + sourceBySeason: { default: 'tencent' } + }); + const defaultPreferenceRequest = new Request('http://localhost/api/v2/match', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fileName: '永生 S05E03' }) + }); + body = await parseResponse(await matchAnime(new URL(defaultPreferenceRequest.url), defaultPreferenceRequest, '127.0.0.1')); + assert.equal(body.matches[0].episodeId, 9300030 + 59); + + AIClient.prototype.ask = async prompt => { + aiMatchInput = JSON.parse(prompt); + return JSON.stringify({ animeIndex: 0 }); + }; + body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '永生 S05E02->永生 S01E58' }, '永生 S05E03', true); + assert.equal(body.matches[0].episodeId, 9300030 + 59); + assert.deepEqual( + { title: aiMatchInput.title, season: aiMatchInput.season, episode: aiMatchInput.episode }, + { title: '永生', season: 1, episode: 59 } + ); + AIClient.prototype.ask = originalAiAsk; + Globals.aiValid = false; + + scenario = 'qualified'; + body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '海贼王 S02E01->航海王(1999)【动漫】 S01E62' }, '海贼王 S02E01'); + assert.equal(body.matches[0].animeId, 930002); + assert.equal(body.matches[0].episodeId, 9300020 + 62); + + body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '海贼王 S02E01->航海王(1998)【动漫】 S01E62' }, '海贼王 S02E01'); + assert.equal(body.matches[0].animeId, 930001); + + scenario = 'platform'; + body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '航海王 S01E01->航海王 S01E01 @qiyi' }, '航海王 S01E01 @qq'); + assert.equal(body.matches[0].animeId, 930005); + + scenario = 'naruto'; + resetFavoriteState({ AUTO_MATCH_MAPPING_TABLE: '火影忍者 S01E57->火影忍者 疾风传(2007)【日番】 S01E59' }); + Globals.envs.sourceOrderArr = ['tencent']; + Globals.lastSelectMap.set('火影忍者', { + animeIds: [930007], + preferBySeason: { 1: 930007 }, + sourceBySeason: { 1: '360' }, + offsets: { 1: '58:【youku】 第58集' } + }); + assert.equal(hasSeasonSpecificPreference('火影忍者', 1), false); + const narutoRequest = new Request('http://localhost/api/v2/match', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fileName: '火影忍者 S01E58' }) + }); + body = await parseResponse(await matchAnime(new URL(narutoRequest.url), narutoRequest, '127.0.0.1')); + assert.equal(body.matches[0].animeId, 930006); + assert.equal(body.matches[0].episodeId, 9300060 + 60); + + scenario = 'open'; + resetFavoriteState({ AUTO_MATCH_MAPPING_TABLE: '永生 S05E02->永生 S01E58' }); + Globals.envs.sourceOrderArr = ['tencent']; + Globals.lastSelectMap.set('永生', { + animeIds: [930003], + preferBySeason: { 5: 930003 }, + sourceBySeason: { 5: 'tencent' }, + offsets: { 5: '2:【qq】 第10集' }, + explicitBySeason: { 5: true } + }); + assert.equal(hasSeasonSpecificPreference('永生', 5), true); + const manualRequest = new Request('http://localhost/api/v2/match', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fileName: '永生 S05E03' }) + }); + body = await parseResponse(await matchAnime(new URL(manualRequest.url), manualRequest, '127.0.0.1')); + assert.equal(body.matches[0].episodeId, 9300030 + 11); + assert.equal(Globals.lastSelectMap.get('永生').explicitBySeason['5'], true); + + resetFavoriteState({ + AUTO_MATCH_MAPPING_TABLE: '永生 S05E02->永生 S01E58', + REMEMBER_LAST_SELECT: 'false' + }); + Globals.envs.sourceOrderArr = ['tencent']; + Globals.lastSelectMap.set('永生', { + animeIds: [930003], + preferBySeason: { 5: 930003 }, + sourceBySeason: { 5: 'tencent' }, + offsets: { 5: '2:【qq】 第10集' }, + explicitBySeason: { 5: true } + }); + const disabledPreferenceRequest = new Request('http://localhost/api/v2/match', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ fileName: '永生 S05E03' }) + }); + body = await parseResponse(await matchAnime(new URL(disabledPreferenceRequest.url), disabledPreferenceRequest, '127.0.0.1')); + assert.equal(body.matches[0].episodeId, 9300030 + 59); + + searchKeywords = []; + scenario = 'fallback'; + body = await runMatch({ AUTO_MATCH_MAPPING_TABLE: '原始剧 S01E01->缺失目标 S01E01' }, '原始剧 S01E01'); + assert.equal(body.matches[0].animeTitle, '原始剧'); + assert.deepEqual(searchKeywords, ['缺失目标', '原始剧']); + } finally { + TencentSource.prototype.search = originalSearch; + TencentSource.prototype.handleAnimes = originalHandleAnimes; + TencentSource.prototype.getComments = originalGetComments; + AIClient.prototype.ask = originalAiAsk; + Globals.envs.sourceOrderArr = originalOrder; + Globals.aiValid = originalAiValid; + } + }); + }); + await t.test('danmu text conversion should run after normalization and before filtering and grouping', () => { const baseEnv = { BLOCKED_WORDS: '', @@ -369,6 +624,12 @@ test('worker.js API endpoints', async (t) => { }); addFavorite('Redis favorite', [favoriteSearchResult(anime)], [anime]); Globals.favoriteCache.get('Redis favorite').timestamp = Date.now() - 24 * 60 * 60 * 1000; + Globals.lastSelectMap.set('Redis preference', { + animeIds: [anime.animeId], + preferBySeason: { 1: anime.animeId }, + sourceBySeason: { 1: 'tencent' }, + explicitBySeason: { 1: true } + }); const redisData = new Map(); const redisCommands = []; @@ -403,10 +664,33 @@ test('worker.js API endpoints', async (t) => { 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); + assert.equal(Globals.lastSelectMap.get('Redis preference').explicitBySeason['1'], true); Globals.redisValid = false; }); + await t.test('clearing runtime caches preserves favorites and auto match mapping configuration', async () => { + resetFavoriteState({ + AUTO_MATCH_MAPPING_TABLE: '火影忍者 S01E57->火影忍者 疾风传(2007)【日番】 S01E59', + LOG_LEVEL: 'error' + }); + const anime = createFavoriteAnime('火影忍者'); + addFavorite('火影忍者', [favoriteSearchResult(anime)], [anime]); + Globals.lastSelectMap.set('火影忍者', { + animeIds: [anime.animeId], + preferBySeason: { 1: anime.animeId }, + sourceBySeason: { 1: 'tencent' }, + explicitBySeason: { 1: true } + }); + + const response = await handleClearCache(); + const body = await parseResponse(response); + assert.equal(body.success, true); + assert.equal(Globals.lastSelectMap.size, 0); + assert.equal(resolveFavoriteForKeyword('火影忍者')?.entry.results[0].animeId, anime.animeId); + assert.equal(Globals.envs.autoMatchMappingTable.length, 1); + }); + await t.test('favorite cache', async t => { await t.test('add/list/remove and serialization round trip', () => { resetFavoriteState(); @@ -439,7 +723,7 @@ test('worker.js API endpoints', async (t) => { setSearchCache('新缓存', [], new Map()); assert.equal(isSearchCacheValid('收藏测试_S9'), true); - assert.equal(getSearchCache('收藏测试剧场版')[0].animeId, anime.animeId); + assert.equal(getSearchCache('收藏测试_S9')[0].animeId, anime.animeId); assert.equal(Globals.favoriteCache.has('收藏测试'), true); assert.equal(Globals.searchCache.has('收藏测试'), true); assert.equal(Globals.searchCache.has('普通过期缓存'), false); @@ -479,6 +763,25 @@ test('worker.js API endpoints', async (t) => { } }); + await t.test('partial search keyword does not reuse a longer favorite title', async () => { + resetFavoriteState(); + const favoriteAnime = createFavoriteAnime('火影忍者', 2, 910011); + const searchAnimeResult = createFavoriteAnime('忍者战士飞影', 2, 910012); + addFavorite('火影忍者', [favoriteSearchResult(favoriteAnime)], [favoriteAnime]); + Globals.searchCache.set('忍者', { + results: [favoriteSearchResult(searchAnimeResult)], + details: [searchAnimeResult], + timestamp: Date.now() + }); + + assert.equal(getSearchCache('火影忍者_S1')[0].animeId, favoriteAnime.animeId); + assert.equal(getSearchCache('忍者')[0].animeId, searchAnimeResult.animeId); + + const response = await searchAnime(new URL('http://localhost/api/v2/search/anime?keyword=忍者')); + const body = await parseResponse(response); + assert.equal(body.animes[0].animeId, searchAnimeResult.animeId); + }); + await t.test('favorite API add/list/remove follows token path normalization', async () => { resetFavoriteState(); const anime = createFavoriteAnime('路由收藏测试'); @@ -604,6 +907,8 @@ test('worker.js API endpoints', async (t) => { assert.doesNotMatch(systemSettingsJsContent, /switchCategory\('favorite'\)/); assert.doesNotThrow(() => new Function(apitestJsContent)); assert.doesNotThrow(() => new Function(systemSettingsJsContent)); + assert.doesNotThrow(() => new Function(previewJsContent)); + assert.match(previewJsContent, /AUTO_MATCH_MAPPING_TABLE/); }); });