diff --git a/README.md b/README.md index 16598a1..c2a0307 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ LogVar 弹幕 API 服务器 - 解决部分播放器不支持顶部/底部弹幕和彩色弹幕的问题 - 增加点赞数显示,先去重再拼接点赞标记,点赞数缩写显示,≥5 才显示,避免低赞干扰 - **弹幕限制数量**:支持通过环境变量配置等间隔采样弹幕数量。 -- **弹幕时间偏移功能**:支持通过环境变量 `DANMU_OFFSET` 配置弹幕时间偏移,解决弹幕与视频不同步的问题。格式为 `剧名:秒`(全剧偏移)、`剧名/季:秒`(整季偏移)、`剧名/季/集:秒`(单集偏移),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02:120, re-zero/S02/E03:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。 +- **弹幕时间偏移功能**:支持通过环境变量 `DANMU_OFFSET` 配置弹幕时间偏移,解决弹幕与视频不同步的问题。格式为 `剧名:秒`(全剧偏移)、`剧名/季:秒`(整季偏移)、`剧名/季/集:秒`(单集偏移),支持指定来源 `剧名@来源:秒`、`剧名/季@来源1&来源2:秒`(不指定来源则对所有来源生效),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02@bilibili:120, re-zero/S02/E03@dandan&bilibili:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。 - **弹幕分片请求**: - `/api/v2/comment` 请求时支持定义 `segmentflag=true` 参数,用于请求弹幕分片列表 - `/api/v2/comment/:commentId?format=json&duration=true` 可在 JSON 返回体中附带 `videoDuration` @@ -401,7 +401,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 | LIKE_SWITCH | 【可选】弹幕点赞数显示开关,默认为`true`(开启),开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰 | | DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 | | DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) | -| DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02:120, re-zero/S02/E03:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。 | +| DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒(不指定来源则对所有来源生效),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02@bilibili:120, re-zero/S02/E03@dandan&bilibili:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。 | | PROXY_URL | 【可选】代理/反代地址,目前只对巴哈姆特、TMDB API、bilibili生效,支持格式:
正常代理:`http://127.0.0.1:7890`
万能反代:`@http://127.0.0.1`
特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)
混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址`
优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。
(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用bahamut源请在SOURCE_ORDER环境变量中手动添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址或者反代([Netlify反代教程](https://github.com/wan0ge/bahamut-api-proxy));vercel/netlify/cf中理应都自然能联通,不用填写 | | TMDB_API_KEY | 【可选】TMDB API Key地址,目前只对巴哈姆特生效,配置后并行从TMDB获取日语原名搜索巴哈(如果TMDB条目类型不是动画或制作地区不是jp则不会进行巴哈搜索)可以解决巴哈译名不同导致的搜索无结果问题,例如大陆常用译名`间谍过家家`在巴哈译名为`間諜家家酒`,正常搜索无法搜索到,配置后可以解决这一问题但会稍微影响请求速度,[TMDBAPI](https://www.themoviedb.org/settings/api)获取方法参考:[TMDB API Key申请 - 绿联NAS私有云](https://www.ugnas.com/tutorial-detail/id-226.html) | | RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 | diff --git a/config/.env.example b/config/.env.example index c085965..9df0709 100644 --- a/config/.env.example +++ b/config/.env.example @@ -160,8 +160,9 @@ CONVERT_COLOR=default DANMU_SIMPLIFIED_TRADITIONAL=default # 弹幕时间偏移配置 -# 格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,正数表示弹幕延后(向右),负数表示弹幕提前(向左),多条用逗号分隔 -# 示例:overlord/S01:90,re-zero/S02:120,re-zero/S02/E03:10,re-zero/S02/E07:150 +# 格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒(不指定来源则对所有来源生效) +# 正数表示弹幕延后(向右),负数表示弹幕提前(向左),多条用逗号分隔 +# 示例:overlord/S01:90,re-zero/S02@bilibili:120,re-zero/S02/E03@dandan&bilibili:10 # 默认值:空 DANMU_OFFSET= diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 647e810..2d8bb2c 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -9,7 +9,8 @@ import { getSearchCache, removeEarliestAnime, setPreferByAnimeId, setSearchCache, storeAnimeIdsToMap, writeCacheToFile, updateLocalCaches, setLastSearch, getLastSearch, findAnimeTitleById, findIndexById } from "../utils/cache-util.js"; -import { formatDanmuResponse, convertToDanmakuJson, getOffset, applyDanmuOffset } from "../utils/danmu-util.js"; +import { formatDanmuResponse, convertToDanmakuJson } from "../utils/danmu-util.js"; +import { resolveOffset, applyOffset } from "../utils/offset-util.js"; import { extractEpisodeTitle, convertChineseNumber, parseFileName, createDynamicPlatformOrder, normalizeSpaces, extractYear, titleMatches, extractAnimeInfo, extractEpisodeNumberFromTitle @@ -1313,7 +1314,7 @@ export async function getBangumi(path) { * @param {string} url 聚合URL * @returns {Promise} 合并后的弹幕列表 */ -async function fetchMergedComments(url) { +async function fetchMergedComments(url, animeTitle, commentId) { const parts = url.split(MERGE_DELIMITER); const sourceNames = parts.map(part => part.split(':')[0]).filter(Boolean); const sourceTag = sourceNames.join('&'); @@ -1429,7 +1430,29 @@ async function fetchMergedComments(url) { // 执行对齐函数 alignSourceTimelines(results, sourceNames, realIds, dandanShifts); } - + + // 按来源分别应用弹幕时间偏移(对齐后、合并前) + if (globals.danmuOffsetRules?.length > 0 && animeTitle && commentId) { + const [, , episodeTitle] = findAnimeIdByCommentId(commentId); + if (episodeTitle) { + let { baseTitle, season, episode } = extractAnimeInfo(animeTitle, episodeTitle); + season ||= 1; + episode ||= findIndexById(commentId) + 1; + const seasonStr = `S${season.toString().padStart(2, '0')}`; + const episodeStr = `E${episode.toString().padStart(2, '0')}`; + + results.forEach((list, idx) => { + const offset = resolveOffset(globals.danmuOffsetRules, { + anime: baseTitle, season: seasonStr, episode: episodeStr, source: sourceNames[idx] + }); + if (offset !== 0 && Array.isArray(list)) { + log("info", `[Merge] 应用偏移 ${offset}s -> ${sourceNames[idx]} (${baseTitle}/${seasonStr}/${episodeStr})`); + results[idx] = applyOffset(list, offset); + } + }); + } + } + // 3. 合并数据 let mergedList = []; results.forEach(list => { @@ -1475,7 +1498,7 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null; if (url && url.includes(MERGE_DELIMITER)) { - danmus = await fetchMergedComments(url); + danmus = await fetchMergedComments(url, animeTitle, commentId); } else { if (url.includes('.qq.com')) { danmus = await tencentSource.getComments(url, plat, segmentFlag); @@ -1561,17 +1584,19 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp, inclu } } - // 应用弹幕时间偏移 - if (animeTitle && episodeTitle && globals.danmuOffset) { + // 应用弹幕时间偏移(合并源已在 fetchMergedComments 中按来源分别应用) + if (animeTitle && episodeTitle && globals.danmuOffsetRules?.length > 0 && !(url && url.includes(MERGE_DELIMITER))) { let { baseTitle, season, episode } = extractAnimeInfo(animeTitle, episodeTitle); season ||= 1; episode ||= findIndexById(commentId) + 1; const seasonStr = `S${season.toString().padStart(2, '0')}`; const episodeStr = `E${episode.toString().padStart(2, '0')}`; - const offset = getOffset(baseTitle, seasonStr, episodeStr); + const offset = resolveOffset(globals.danmuOffsetRules, { + anime: baseTitle, season: seasonStr, episode: episodeStr, source + }); if (offset !== 0) { log("info", `Applying danmu offset: ${offset}s for ${baseTitle}/${seasonStr}/${episodeStr}`); - danmus = applyDanmuOffset(danmus, offset); + danmus = applyOffset(danmus, offset); } } diff --git a/danmu_api/configs/envs.js b/danmu_api/configs/envs.js index 830f0e1..a6db19e 100644 --- a/danmu_api/configs/envs.js +++ b/danmu_api/configs/envs.js @@ -2,6 +2,8 @@ * 环境变量管理模块 * 提供获取和设置环境变量的函数,支持 Cloudflare Workers 和 Node.js */ +import { parseOffsetRules } from '../utils/offset-util.js'; + export class Envs { static env; @@ -525,7 +527,7 @@ export class Envs { 'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' }, 'DANMU_PUSH_URL': { category: 'danmu', type: 'text', description: '弹幕推送地址,示例 http://127.0.0.1:9978/action?do=refresh&type=danmaku&path= ' }, 'LIKE_SWITCH': { category: 'danmu', type: 'boolean', description: '弹幕点赞数显示开关,默认开启' }, - 'DANMU_OFFSET': { category: 'danmu', type: 'text', description: '弹幕时间偏移配置,格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,多条用逗号分隔,正数表示弹幕延后(向右),负数表示弹幕提前(向左),例如:overlord/S01:90,re-zero/S02:120,re-zero/S02/E03:10,re-zero/S02/E07:150' }, + 'DANMU_OFFSET': { category: 'danmu', type: 'text', sources: this.ALLOWED_SOURCES, description: '弹幕时间偏移配置,格式:剧名:秒 或 剧名/季:秒 或 剧名/季/集:秒,支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒,多条用逗号分隔,正数表示弹幕延后(向右),负数表示弹幕提前(向左),例如:overlord/S01:90,re-zero/S02@bilibili:120,re-zero/S02/E03@dandan&bilibili:10' }, // 缓存配置 'SEARCH_CACHE_MINUTES': { category: 'cache', type: 'number', description: '搜索结果缓存时间(分钟),默认1', min: 1, max: 120 }, @@ -573,6 +575,7 @@ export class Envs { danmuPushUrl: this.get('DANMU_PUSH_URL', '', 'string'), // 代理/反代地址 likeSwitch: this.get('LIKE_SWITCH', true, 'boolean'), // 弹幕点赞数显示开关,默认开启 danmuOffset: this.get('DANMU_OFFSET', '', 'string'), // 弹幕时间偏移配置 + danmuOffsetRules: parseOffsetRules(this.get('DANMU_OFFSET', '', 'string')), // 解析后的偏移规则(缓存) tmdbApiKey: this.get('TMDB_API_KEY', '', 'string', true), // TMDB API KEY redisUrl: this.get('UPSTASH_REDIS_REST_URL', '', 'string', true), // upstash redis url redisToken: this.get('UPSTASH_REDIS_REST_TOKEN', '', 'string', true), // upstash redis url diff --git a/danmu_api/ui/css/components.css.js b/danmu_api/ui/css/components.css.js index 967afaf..2a8b8bb 100644 --- a/danmu_api/ui/css/components.css.js +++ b/danmu_api/ui/css/components.css.js @@ -374,6 +374,8 @@ export const componentsCssContent = /* css */ ` border-radius: 12px; max-width: 600px; width: 100%; + max-height: calc(100vh - 40px); + overflow-y: auto; box-shadow: 0 10px 40px rgba(0,0,0,0.3); position: relative; top: 0; @@ -1450,4 +1452,66 @@ export const componentsCssContent = /* css */ ` height: 70px; } } + +/* 偏移规则快速配置 */ +.offset-rule-panel { + display: none; + margin-top: 10px; + padding: 12px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background: #f9f9f9; +} + +.offset-form-row { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.offset-label { + font-size: 12px; + color: #666; +} + +.offset-input { + width: 100%; + padding: 6px 8px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + box-sizing: border-box; +} + +.offset-sources { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 5px; +} + +.offset-source-tag { + padding: 3px 10px; + border: 1px solid #ddd; + border-radius: 12px; + font-size: 12px; + cursor: pointer; + user-select: none; + background: #fff; + color: #666; + transition: all 0.15s; +} + +.offset-source-tag.selected { + background: #1a73e8; + color: #fff; + border-color: #1a73e8; +} + +.offset-actions { + display: flex; + gap: 8px; + justify-content: flex-end; +} `; diff --git a/danmu_api/ui/js/main.js b/danmu_api/ui/js/main.js index 2b18aca..32fa5bb 100644 --- a/danmu_api/ui/js/main.js +++ b/danmu_api/ui/js/main.js @@ -204,7 +204,8 @@ function loadEnvVariables() { type: varConfig.type || 'text', min: varConfig.min, max: varConfig.max, - options: varConfig.options || [] // 仅对 select 和 multi-select 类型有效 + options: varConfig.options || [], // 仅对 select 和 multi-select 类型有效 + sources: varConfig.sources || null // 仅对 DANMU_OFFSET 等需要来源配置的有效 }); }); diff --git a/danmu_api/ui/js/systemsettings.js b/danmu_api/ui/js/systemsettings.js index 8aa5e7d..3ae55f1 100644 --- a/danmu_api/ui/js/systemsettings.js +++ b/danmu_api/ui/js/systemsettings.js @@ -566,8 +566,59 @@ function renderValueInput(item) { const currentKey = document.getElementById('env-key') ? document.getElementById('env-key').value : ''; const isBilibiliCookie = currentKey === 'BILIBILI_COOKIE'; const isAiApiKey = currentKey === 'AI_API_KEY'; - - if (isAiApiKey) { + const isDanmuOffset = currentKey === 'DANMU_OFFSET'; + const offsetSources = item && item.sources ? item.sources : []; + + if (isDanmuOffset) { + // DANMU_OFFSET 专用编辑界面 + const rows = value && value.length > 50 ? Math.min(Math.max(Math.ceil(value.length / 50), 3), 10) : 3; + container.innerHTML = \` + + +
+ +
+
+
季和集不填则对所有季/集生效
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ \${offsetSources.length > 0 ? \` +
+ +
+ \${offsetSources.map(src => \` +
+ \${src} +
+ \`).join('')} +
+
+ \` : ''} +
+ + +
+
+ \`; + } else if (isAiApiKey) { // AI API Key 专用编辑界面 container.innerHTML = \`
@@ -638,6 +689,68 @@ function renderValueInput(item) { } } +// DANMU_OFFSET 快速配置 - 切换规则面板 +function toggleOffsetRulePanel() { + const panel = document.getElementById('offset-rule-panel'); + if (panel) { + const isHidden = getComputedStyle(panel).display === 'none'; + panel.style.display = isHidden ? 'block' : 'none'; + const btn = document.getElementById('offset-rule-toggle'); + if (btn) btn.textContent = isHidden ? '收起' : '添加规则'; + } +} + +// DANMU_OFFSET 快速配置 - 切换来源选中状态 +function toggleOffsetSource(el) { + el.classList.toggle('selected'); +} + +// DANMU_OFFSET 快速配置 - 确认添加规则 +function appendOffsetRule() { + const anime = document.getElementById('offset-anime').value.trim(); + const season = document.getElementById('offset-season').value.trim(); + const episode = document.getElementById('offset-episode').value.trim(); + const seconds = document.getElementById('offset-seconds').value.trim(); + + if (!anime) { customAlert('请输入剧名'); return; } + if (!seconds) { customAlert('请输入偏移秒数'); return; } + if (episode && !season) { customAlert('指定集时需要同时指定季'); return; } + + let path = anime; + if (season) { + path += '/S' + season.padStart(2, '0'); + if (episode) { + path += '/E' + episode.padStart(2, '0'); + } + } + + const sourcesEl = document.getElementById('offset-sources'); + if (sourcesEl) { + const selectedSources = Array.from(sourcesEl.querySelectorAll('.offset-source-tag.selected')) + .map(el => el.dataset.value); + if (selectedSources.length > 0) { + path += '@' + selectedSources.join('&'); + } + } + + const rule = path + ':' + seconds; + const textarea = document.getElementById('text-value'); + const current = textarea.value.trim(); + textarea.value = current ? current + ',' + rule : rule; + + // 重置表单 + document.getElementById('offset-anime').value = ''; + document.getElementById('offset-season').value = ''; + document.getElementById('offset-episode').value = ''; + document.getElementById('offset-seconds').value = ''; + if (sourcesEl) { + sourcesEl.querySelectorAll('.offset-source-tag.selected').forEach(el => { + el.classList.remove('selected'); + }); + } + toggleOffsetRulePanel(); +} + // 调整数字 function adjustNumber(delta) { const display = document.getElementById('num-value'); diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index a8f0dbd..bca50f6 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -504,56 +504,3 @@ export function formatDanmuResponse(danmuData, queryFormat) { // 默认返回 JSON return jsonResponse(danmuData); } - -// 解析偏移量配置 -function parseOffset(env) { - const map = new Map(); - if (!env) return map; - for (const entry of env.split(',')) { - const [path, sec] = entry.trim().split(':'); - if (path && sec !== undefined) { - const normalizedPath = path.trim().split('/').map((segment, index) => { - if (index === 0) return segment; // 剧名不处理 - const seasonMatch = segment.match(/^S(\d+)$/i); - if (seasonMatch) return `S${String(parseInt(seasonMatch[1])).padStart(2, '0')}`; - const episodeMatch = segment.match(/^E(\d+)$/i); - if (episodeMatch) return `E${String(parseInt(episodeMatch[1])).padStart(2, '0')}`; - return segment; - }).join('/'); - map.set(normalizedPath, parseInt(sec)); - } - } - return map; -} - -// 获取偏移量 -export function getOffset(anime, season, episode) { - const map = parseOffset(globals.danmuOffset); - log("info", `getOffset params: ${anime}, ${season}, ${episode}`); - log("info", `getOffset map: ${JSON.stringify([...map])}`); - return ( - map.get(`${anime}/${season}/${episode}`) ?? // 集级 - map.get(`${anime}/${season}`) ?? // 季级 - map.get(anime) ?? // 剧级 - 0 - ); -} - -// 应用弹幕时间偏移 -export function applyDanmuOffset(danmus, offset) { - if (offset === 0) return danmus; - - return danmus.map(danmu => { - // 解析时间参数 - const pValues = danmu.p.split(','); - const time = parseFloat(pValues[0]) || 0; - const newTime = Math.max(0, time + offset); // 确保时间不为负数 - pValues[0] = newTime.toFixed(3); // 保留3位小数 - - return { - ...danmu, - p: pValues.join(','), - t: newTime // 如果有t字段也更新 - }; - }); -} diff --git a/danmu_api/utils/offset-util.js b/danmu_api/utils/offset-util.js new file mode 100644 index 0000000..73b2f9b --- /dev/null +++ b/danmu_api/utils/offset-util.js @@ -0,0 +1,195 @@ +// 弹幕时间偏移独立模块 +// 职责:解析偏移规则、匹配偏移量、应用偏移到弹幕 + +// 来源→平台别名映射(source 名和 platform 名不一致时扩展匹配) +const SOURCE_ALIASES = { + tencent: 'qq', + iqiyi: 'qiyi', + bilibili: 'bilibili1' +}; + +// 规范化季/集编号(S1→S01, E3→E03) +function normalizeSegment(segment) { + const seasonMatch = segment.match(/^S(\d+)$/i); + if (seasonMatch) return `S${String(parseInt(seasonMatch[1])).padStart(2, '0')}`; + const episodeMatch = segment.match(/^E(\d+)$/i); + if (episodeMatch) return `E${String(parseInt(episodeMatch[1])).padStart(2, '0')}`; + return segment; +} + +/** + * 解析 DANMU_OFFSET 环境变量为结构化规则数组(启动时调用一次,缓存到 globals) + * @param {string} env 环境变量值 + * @returns {Array} 规则数组 + */ +export function parseOffsetRules(env) { + if (!env || typeof env !== 'string') return []; + + return env.split(',').map(entry => { + const trimmed = entry.trim(); + const colonIdx = trimmed.lastIndexOf(':'); + if (colonIdx === -1) return null; + + const rawPath = trimmed.substring(0, colonIdx); + const offsetStr = trimmed.substring(colonIdx + 1); + if (!rawPath || offsetStr === '') return null; + + const offset = parseFloat(offsetStr); + if (!Number.isFinite(offset)) return null; + + // 分离 @来源 + const atIdx = rawPath.lastIndexOf('@'); + let pathPart, sources, all; + + if (atIdx !== -1) { + pathPart = rawPath.substring(0, atIdx); + const sourcePart = rawPath.substring(atIdx + 1).trim().toLowerCase(); + if (sourcePart === 'all' || sourcePart === '*') { + sources = null; + all = true; + } else { + sources = sourcePart.split('&').map(s => s.trim()).filter(Boolean); + all = false; + if (sources.length === 0) return null; + } + } else { + pathPart = rawPath; + sources = null; + all = false; + } + + // 解析路径:剧名/季/集 + const segments = pathPart.trim().split('/'); + const anime = segments[0] || null; + if (!anime) return null; + + const season = segments[1] ? normalizeSegment(segments[1]) : null; + const episode = segments[2] ? normalizeSegment(segments[2]) : null; + + return { anime, season, episode, sources, all, offset }; + }).filter(Boolean); +} + +/** + * 根据规则查找匹配的偏移量 + * @param {Array} rules parseOffsetRules 返回的规则数组 + * @param {Object} ctx 匹配上下文 + * @param {string} ctx.anime 剧名 + * @param {string} ctx.season 季(如 S01) + * @param {string} ctx.episode 集(如 E03) + * @param {string} ctx.source 来源(如 'bilibili' 或合并来源 'dandan&bilibili1') + * @returns {number} 偏移秒数,无匹配返回 0 + */ +export function resolveOffset(rules, { anime, season, episode, source }) { + if (!Array.isArray(rules) || rules.length === 0 || !anime) return 0; + + // 拆分合并来源,并展开别名 + const sourceKeys = new Set(); + if (source) { + for (const s of source.split('&')) { + const trimmed = s.trim().toLowerCase(); + if (trimmed) { + sourceKeys.add(trimmed); + if (SOURCE_ALIASES[trimmed]) sourceKeys.add(SOURCE_ALIASES[trimmed]); + } + } + } + + // 路径级别:集级 > 季级 > 剧级 + const levels = [ + { matchSeason: true, matchEpisode: true }, // 集级 + { matchSeason: true, matchEpisode: false }, // 季级 + { matchSeason: false, matchEpisode: false } // 剧级 + ]; + + for (const level of levels) { + // 同一路径级别内,按优先级:来源特定 > all 通配 > 无限定 + let specificMatch = null; + let allMatch = null; + let genericMatch = null; + + for (const rule of rules) { + // 匹配剧名 + if (rule.anime !== anime) continue; + + // 匹配路径级别 + if (level.matchEpisode) { + if (!rule.season || !rule.episode) continue; + if (rule.season !== season || rule.episode !== episode) continue; + } else if (level.matchSeason) { + if (!rule.season || rule.episode) continue; + if (rule.season !== season) continue; + } else { + if (rule.season || rule.episode) continue; + } + + // 匹配来源 + if (rule.sources) { + // 来源特定规则 + if (sourceKeys.size > 0 && rule.sources.some(s => sourceKeys.has(s))) { + specificMatch = rule.offset; + } + } else if (rule.all) { + allMatch = rule.offset; + } else { + genericMatch = rule.offset; + } + } + + // 按优先级返回 + if (specificMatch !== null) return specificMatch; + if (allMatch !== null) return allMatch; + if (genericMatch !== null) return genericMatch; + } + + return 0; +} + +/** + * 应用时间偏移到弹幕数组(兼容多种时间字段格式) + * @param {Array} danmus 弹幕数组 + * @param {number} offsetSeconds 偏移秒数 + * @returns {Array} 偏移后的弹幕数组 + */ +export function applyOffset(danmus, offsetSeconds) { + const offset = Number(offsetSeconds); + if (!offset || !Array.isArray(danmus) || danmus.length === 0) return danmus; + + const offsetMs = offset * 1000; + const clamp = v => Math.max(0, v); + + return danmus.map(danmu => { + if (!danmu || typeof danmu !== 'object') return danmu; + const updated = { ...danmu }; + + // p 字段(逗号分隔,第一段为秒) + if (typeof updated.p === 'string') { + const parts = updated.p.split(','); + const time = parseFloat(parts[0]); + if (Number.isFinite(time)) { + parts[0] = clamp(time + offset).toFixed(3); + updated.p = parts.join(','); + } + } + + // t 字段(秒) + if (updated.t !== undefined && updated.t !== null) { + const t = Number(updated.t); + if (Number.isFinite(t)) updated.t = clamp(t + offset); + } + + // progress 字段(毫秒) + if (updated.progress !== undefined && updated.progress !== null) { + const p = Number(updated.progress); + if (Number.isFinite(p)) updated.progress = clamp(p + offsetMs); + } + + // timepoint 字段(秒) + if (updated.timepoint !== undefined && updated.timepoint !== null) { + const tp = Number(updated.timepoint); + if (Number.isFinite(tp)) updated.timepoint = clamp(tp + offset); + } + + return updated; + }); +}