diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 0fb293b1b..6df3739f2 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -11,7 +11,7 @@ import { } from "../utils/cache-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 { resolveOffset, resolveOffsetRule, applyOffset, stripLinkOffset } from "../utils/offset-util.js"; import { filterMappingQualifierCandidates, filterMappingTargetCandidates, resolveAutoMatchMapping } from "../utils/auto-match-mapping-util.js"; import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, @@ -178,9 +178,21 @@ async function resolveMergedDuration(url) { if (!url) return 0; try { - const targetUrls = url.includes(MERGE_DELIMITER) ? extractMergedUrls(url) : [url]; - const durations = await Promise.all(targetUrls.map(resolveUrlDuration)); - return durations.reduce((maxValue, currentValue) => Math.max(maxValue, currentValue || 0), 0); + // 单链接直接返回其时长 + if (!url.includes(MERGE_DELIMITER)) { + return await resolveUrlDuration(url); + } + + // 合并链接的时长取各子链接偏移后时间轴末端的最大值,而非各子链接时长的简单取大 + const linkMetas = extractMergedUrls(url).map(stripLinkOffset); + const durations = await Promise.all(linkMetas.map((meta) => resolveUrlDuration(meta.cleanUrl))); + // 复用 applyOffset 的偏移语义计算各子链接末端,确保返回的合并时长与弹幕实际落点完全一致 + return linkMetas.reduce((maxEnd, meta, index) => { + const sourceDuration = durations[index]; + if (!(sourceDuration > 0)) return maxEnd; + const end = applyOffset([{ t: sourceDuration }], meta.offset, { usePercent: meta.percent, videoDuration: sourceDuration })[0].t; + return end > maxEnd ? end : maxEnd; + }, 0); } catch (error) { log('warn', `[system] [duration] 获取时长失败: ${error.message}`); return 0; @@ -614,7 +626,7 @@ async function searchAnimeBody(url, preferAnimeId = null, preferSource = null, d // 多链接合并解析:空格分隔的多个 URL → 聚合弹幕 const urlRegex = /^(https?:\/\/)?([a-zA-Z0-9-]+\.)+[a-zA-Z]{2,6}(:\d+)?(\/[^\s]*)?$/; const spaceSeparatedUrls = queryTitle.split(/\s+/).filter(u => { - const cleanUrl = u.replace(/@%?-?\d+(?:\.\d+)?$/, ''); + const cleanUrl = stripLinkOffset(u).cleanUrl; return urlRegex.test(cleanUrl); }); if (spaceSeparatedUrls.length >= 2) { @@ -636,7 +648,7 @@ async function searchAnimeBody(url, preferAnimeId = null, preferSource = null, d } else if (source === 'bahamut') { titles.push(`【bahamut】 BahaSn${singleUrl.match(/sn=(\d+)/)?.[1] || '?'}`); } else { - const pt = await sourceLogContext.run(toLogSourceName(source), () => getPageTitle(singleUrl)); + const pt = await sourceLogContext.run(toLogSourceName(source), () => getPageTitle(stripLinkOffset(singleUrl).cleanUrl)); titles.push(`【${source}】 ${pt}`); } } @@ -736,8 +748,8 @@ async function searchAnimeBody(url, preferAnimeId = null, preferSource = null, d } else if (platform === 'hongguo') { pageTitle = '红果短剧'; } else { - // 将源标识符统一映射到日志标签规范名称 - pageTitle = await sourceLogContext.run(toLogSourceName(platform), () => getPageTitle(queryTitle)); + // 将源标识符统一映射到日志标签规范名称;取标题前剥离 @偏移 后缀,避免带偏移的链接请求失败 + pageTitle = await sourceLogContext.run(toLogSourceName(platform), () => getPageTitle(stripLinkOffset(queryTitle).cleanUrl)); } const links = [{ @@ -1710,14 +1722,14 @@ function resolveSourceAndRealId(url) { // Animeko: bgm.tv/bangumi.tv/chii.in/bangumi.lol/ep/xxx → animeko:xxx(@offset) const bgmMatch = url.match(/(?:bgm\.tv|bangumi\.tv|bangumi\.lol|chii\.in)\/ep\/(\d+)/); if (bgmMatch) { - const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/); - return { source: 'animeko', realId: bgmMatch[1] + (offsetMatch ? offsetMatch[0] : '') }; + const { offset, percent } = stripLinkOffset(url); + return { source: 'animeko', realId: bgmMatch[1] + (offset !== 0 ? `@${offset}${percent ? '%' : ''}` : '') }; } // Bahamut: ani.gamer.com.tw/animeVideo.php?sn=xxx → bahamut:xxx(@offset) const bahaMatch = url.match(/ani\.gamer\.com\.tw\/animeVideo\.php\?sn=(\d+)/); if (bahaMatch) { - const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/); - return { source: 'bahamut', realId: bahaMatch[1] + (offsetMatch ? offsetMatch[0] : '') }; + const { offset, percent } = stripLinkOffset(url); + return { source: 'bahamut', realId: bahaMatch[1] + (offset !== 0 ? `@${offset}${percent ? '%' : ''}` : '') }; } // 其他平台:直接传递完整 URL const source = detectPlatformFromUrl(url); @@ -2359,20 +2371,10 @@ async function fetchMergedComments(url, animeTitle, commentId) { let realId = part.substring(firstColonIndex + 1); // 提取链接尾部偏移值(@100/@-50 秒数偏移,@%30/@%-11 百分比偏移) - let manualOffset = 0; - let manualOffsetPercent = false; - const percentMatch = realId.match(/@%(-?\d+(?:\.\d+)?)$/); - if (percentMatch) { - manualOffset = parseFloat(percentMatch[1]); - manualOffsetPercent = true; - realId = realId.substring(0, realId.length - percentMatch[0].length); - } else { - const offsetMatch = realId.match(/@(-?\d+(?:\.\d+)?)$/); - if (offsetMatch) { - manualOffset = parseFloat(offsetMatch[1]); - realId = realId.substring(0, realId.length - offsetMatch[0].length); - } - } + const linkMeta = stripLinkOffset(realId); + const manualOffset = linkMeta.offset; + const manualOffsetPercent = linkMeta.percent; + realId = linkMeta.cleanUrl; if (sourceName !== 'hanjutv') { return { @@ -2608,22 +2610,12 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null; // 提取单链接偏移值(@秒数 / @%百分比) - let singleUrlOffset = 0; - let singleUrlOffsetPercent = false; - let cleanUrl = url; - const percentMatch = url.match(/@%(-?\d+(?:\.\d+)?)$/); - if (percentMatch) { - singleUrlOffset = parseFloat(percentMatch[1]); - singleUrlOffsetPercent = true; - cleanUrl = url.substring(0, url.length - percentMatch[0].length); - log("info", `[system] [LogVar-API] 检测到链接百分比偏移: ${singleUrlOffset}s`); - } else { - const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/); - if (offsetMatch) { - singleUrlOffset = parseFloat(offsetMatch[1]); - cleanUrl = url.substring(0, url.length - offsetMatch[0].length); - log("info", `[system] [LogVar-API] 检测到链接偏移: ${singleUrlOffset}s`); - } + const linkMeta = stripLinkOffset(url); + const singleUrlOffset = linkMeta.offset; + const singleUrlOffsetPercent = linkMeta.percent; + const cleanUrl = linkMeta.cleanUrl; + if (singleUrlOffset !== 0) { + log("info", `[system] [LogVar-API] 检测到链接${singleUrlOffsetPercent ? '百分比' : ''}偏移: ${singleUrlOffset}s`); } if (url && url.includes(MERGE_DELIMITER)) { @@ -2696,8 +2688,8 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu } } - // 单链接偏移值应用 - if (singleUrlOffset !== 0 && danmus && Array.isArray(danmus) && danmus.length > 0) { + // 单链接偏移值应用(合并链接已在 fetchMergedComments 中按来源分别应用,此处仅处理单链接) + if (!(url && url.includes(MERGE_DELIMITER)) && singleUrlOffset !== 0 && danmus && Array.isArray(danmus) && danmus.length > 0) { if (singleUrlOffsetPercent) { const maxTime = Math.max(...danmus.map(d => parseFloat(String(d.p).split(',')[0]) || 0), 0); danmus = applyOffset(danmus, singleUrlOffset, { usePercent: true, videoDuration: maxTime || 1 }); @@ -2850,45 +2842,67 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includ let danmus = []; const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null; + // 提取单链接偏移值(@秒数 / @%百分比) + const linkMeta = stripLinkOffset(url); + const singleUrlOffset = linkMeta.offset; + const singleUrlOffsetPercent = linkMeta.percent; + const cleanUrl = linkMeta.cleanUrl; + if (singleUrlOffset !== 0) { + log("info", `[system] [LogVar-API] 检测到链接${singleUrlOffsetPercent ? '百分比' : ''}偏移: ${singleUrlOffset}s`); + } + // 根据URL域名判断平台并获取弹幕 if (url.includes('.qq.com')) { - danmus = await sourceLogContext.run('tencent', () => tencentSource.getComments(url, "qq", segmentFlag)); + danmus = await sourceLogContext.run('tencent', () => tencentSource.getComments(cleanUrl, "qq", segmentFlag)); } else if (url.includes('.iqiyi.com')) { - danmus = await sourceLogContext.run('iqiyi', () => iqiyiSource.getComments(url, "qiyi", segmentFlag)); + danmus = await sourceLogContext.run('iqiyi', () => iqiyiSource.getComments(cleanUrl, "qiyi", segmentFlag)); } else if (url.includes('.mgtv.com')) { - danmus = await sourceLogContext.run('mango', () => mangoSource.getComments(url, "imgo", segmentFlag)); + danmus = await sourceLogContext.run('mango', () => mangoSource.getComments(cleanUrl, "imgo", segmentFlag)); } else if (url.includes('.bilibili.com') || url.includes('b23.tv')) { // 如果是 b23.tv 短链接,先解析为完整 URL - if (url.includes('b23.tv')) { - url = await sourceLogContext.run('bilibili', () => bilibiliSource.resolveB23Link(url)); + let resolvedUrl = cleanUrl; + if (resolvedUrl.includes('b23.tv')) { + resolvedUrl = await sourceLogContext.run('bilibili', () => bilibiliSource.resolveB23Link(resolvedUrl)); } - danmus = await sourceLogContext.run('bilibili', () => bilibiliSource.getComments(url, "bilibili1", segmentFlag)); + danmus = await sourceLogContext.run('bilibili', () => bilibiliSource.getComments(resolvedUrl, "bilibili1", segmentFlag)); } else if (url.includes('.youku.com')) { - danmus = await sourceLogContext.run('youku', () => youkuSource.getComments(url, "youku", segmentFlag)); + danmus = await sourceLogContext.run('youku', () => youkuSource.getComments(cleanUrl, "youku", segmentFlag)); } else if (url.includes('.miguvideo.com')) { - danmus = await sourceLogContext.run('migu', () => miguSource.getComments(url, "migu", segmentFlag)); + danmus = await sourceLogContext.run('migu', () => miguSource.getComments(cleanUrl, "migu", segmentFlag)); } else if (url.includes('.sohu.com')) { - danmus = await sourceLogContext.run('sohu', () => sohuSource.getComments(url, "sohu", segmentFlag)); + danmus = await sourceLogContext.run('sohu', () => sohuSource.getComments(cleanUrl, "sohu", segmentFlag)); } else if (url.includes('.le.com')) { - danmus = await sourceLogContext.run('leshi', () => leshiSource.getComments(url, "leshi", segmentFlag)); + danmus = await sourceLogContext.run('leshi', () => leshiSource.getComments(cleanUrl, "leshi", segmentFlag)); } else if (url.includes('.douyin.com') || url.includes('.ixigua.com')) { - danmus = await sourceLogContext.run('xigua', () => xiguaSource.getComments(url, "xigua", segmentFlag)); + danmus = await sourceLogContext.run('xigua', () => xiguaSource.getComments(cleanUrl, "xigua", segmentFlag)); } else if (url.includes('.mddcloud.com.cn')) { - danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getComments(url, "maiduidui", segmentFlag)); + danmus = await sourceLogContext.run('maiduidui', () => maiduiduiSource.getComments(cleanUrl, "maiduidui", segmentFlag)); } else if (url.includes('.yfsp.tv')) { - danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getComments(url, "aiyifan", segmentFlag)); - } else if (isHongguoPlayerUrl(url)) { - danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(url, "hongguo", segmentFlag)); + danmus = await sourceLogContext.run('aiyifan', () => aiyifanSource.getComments(cleanUrl, "aiyifan", segmentFlag)); + } else if (isHongguoPlayerUrl(cleanUrl)) { + danmus = await sourceLogContext.run('hongguo', () => hongguoSource.getComments(cleanUrl, "hongguo", segmentFlag)); } else { // 如果不是已知平台,尝试第三方弹幕服务器 const urlPattern = /^(https?:\/\/)?([\w.-]+)\.([a-z]{2,})(\/.*)?$/i; - if (urlPattern.test(url)) { - danmus = await sourceLogContext.run('other', () => otherSource.getComments(url, "other_server", segmentFlag)); + if (urlPattern.test(cleanUrl)) { + danmus = await sourceLogContext.run('other', () => otherSource.getComments(cleanUrl, "other_server", segmentFlag)); } } log("info", `[system] [LogVar-API] Successfully fetched ${danmus.length} comments from URL`); + // 单链接偏移值应用 + if (singleUrlOffset !== 0 && danmus && Array.isArray(danmus) && danmus.length > 0) { + if (singleUrlOffsetPercent) { + const maxTime = Math.max(...danmus.map(d => parseFloat(String(d.p).split(',')[0]) || 0), 0); + danmus = applyOffset(danmus, singleUrlOffset, { usePercent: true, videoDuration: maxTime || 1 }); + log("info", `[system] [LogVar-API] 应用链接百分比偏移 ${singleUrlOffset}s (时长=${maxTime}s)`); + } else { + danmus = applyOffset(danmus, singleUrlOffset); + log("info", `[system] [LogVar-API] 应用链接偏移 ${singleUrlOffset}s`); + } + } + // 缓存弹幕结果 if (danmus.length > 0) { setCommentCache(cacheKey, danmus); diff --git a/danmu_api/utils/offset-util.js b/danmu_api/utils/offset-util.js index fdf022c9f..f1bdda097 100644 --- a/danmu_api/utils/offset-util.js +++ b/danmu_api/utils/offset-util.js @@ -1,7 +1,7 @@ import { normalizeSpaces } from './common-util.js'; // 弹幕时间偏移独立模块 -// 职责:解析偏移规则、匹配偏移量、应用偏移到弹幕 +// 职责:解析链接 @偏移 与偏移规则、匹配偏移量、应用偏移到弹幕 // 来源→平台别名映射(source 名和 platform 名不一致时扩展匹配) const SOURCE_ALIASES = { @@ -263,3 +263,21 @@ export function applyOffset(danmus, offsetSeconds, options = {}) { return updated; }); } + +/** + * 从播放链接尾部解析 @偏移 后缀(@秒数 / @%百分比),返回剥离后的洁净链接与偏移参数 + * @param {string} rawUrl 原始链接 + * @returns {{cleanUrl: string, offset: number, percent: boolean}} 洁净链接、偏移秒数、是否百分比 + */ +export function stripLinkOffset(rawUrl) { + const url = String(rawUrl ?? ''); + const percentMatch = url.match(/@%(-?\d+(?:\.\d+)?)$/); + if (percentMatch) { + return { cleanUrl: url.substring(0, url.length - percentMatch[0].length), offset: parseFloat(percentMatch[1]), percent: true }; + } + const offsetMatch = url.match(/@(-?\d+(?:\.\d+)?)$/); + if (offsetMatch) { + return { cleanUrl: url.substring(0, url.length - offsetMatch[0].length), offset: parseFloat(offsetMatch[1]), percent: false }; + } + return { cleanUrl: url, offset: 0, percent: false }; +} diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index db8909677..dde29bdd0 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -6,6 +6,7 @@ import test from 'node:test'; import assert from 'node:assert'; import { handleRequest } from './worker.js'; import { extractTitleSeasonEpisode, getBangumi, getComment, getCommentByUrl, matchAnime, searchAnime, buildSearchAnimeUrl } from "./apis/dandan-api.js"; +import { stripLinkOffset, applyOffset } from "./utils/offset-util.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"; @@ -1168,6 +1169,28 @@ test('worker.js API endpoints', async (t) => { }); }); + await t.test('stripLinkOffset 解析 @偏移 后缀(@秒数 / @%百分比 / 无偏移 / 合并链接仅取末段)', async () => { + assert.deepEqual(stripLinkOffset('https://x.com/v/1'), { cleanUrl: 'https://x.com/v/1', offset: 0, percent: false }); + assert.deepEqual(stripLinkOffset('https://x.com/v/1@3197'), { cleanUrl: 'https://x.com/v/1', offset: 3197, percent: false }); + assert.deepEqual(stripLinkOffset('https://x.com/v/1@%50'), { cleanUrl: 'https://x.com/v/1', offset: 50, percent: true }); + assert.deepEqual(stripLinkOffset('https://x.com/v/1@-50'), { cleanUrl: 'https://x.com/v/1', offset: -50, percent: false }); + // 合并链接仅从整条 URL 尾部取末段子链接的 @偏移 + const merged = 'https://x.com/v/A$$$https://x.com/v/B@3197'; + assert.deepEqual(stripLinkOffset(merged), { cleanUrl: 'https://x.com/v/A$$$https://x.com/v/B', offset: 3197, percent: false }); + // 容错:非字符串入参不抛错 + assert.deepEqual(stripLinkOffset(null), { cleanUrl: '', offset: 0, percent: false }); + }); + + await t.test('applyOffset 应用 @偏移 的端点语义(绝对 = 时间+偏移,百分比端点 = 最大时间+偏移)', async () => { + const danmus = [{ p: '10,1,16777215,b' }, { p: '20,1,16777215,b' }]; + // 绝对偏移:每条弹幕时间整体平移 offset 秒 + assert.deepEqual(applyOffset(danmus, 50).map(d => d.p), ['60.00,1,16777215,b', '70.00,1,16777215,b']); + // 百分比偏移:按时间轴缩放,scaleRatio=(maxTime+offset)/maxTime,端点 maxTime → maxTime+offset + assert.deepEqual(applyOffset(danmus, 50, { usePercent: true, videoDuration: 20 }).map(d => d.p), ['35.00,1,16777215,b', '70.00,1,16777215,b']); + // 合并时长端点一致性:单链接时长 3266、偏移 3197 时,合并时间轴末端为 6463 + assert.equal(applyOffset([{ t: 3266 }], 3197, { usePercent: false, videoDuration: 3266 })[0].t, 6463); + }); + // 测试 Bangumi Data 本地检索结果的同源去重 await t.test('dedupeBangumiSearchResults should dedupe same-source results and skip tmdb', () => { const makeResult = (siteKey, siteId, titles) => ({ matchedSiteKey: siteKey, siteId, titles }); @@ -1196,8 +1219,6 @@ test('worker.js API endpoints', async (t) => { assert.equal(crossSite.length, 2, `Expected crossSite.length === 2, but got ${crossSite.length}`); }); - }); - // await t.test('GET /api/v2/comment/:id?format=json&duration=true should return segment duration and reuse comment cache', async () => { // Globals.init({}); // Globals.animes = [];