DANMU_OFFSET 支持按来源指定弹幕偏移,重构偏移系统为独立模块,配置界面增加快速添加规则,修复模态框滚动问题 (#241)

* DANMU_OFFSET 支持按来源指定偏移,配置界面增加快速添加规则,修复模态框滚动问题

- DANMU_OFFSET 扩展 @来源 语法,支持针对特定弹幕来源单独设置偏移量
- 查找优先级:带来源规则优先,fallback 到通用规则,层级从集->季->剧名逐级回退
- 配置界面增加"添加规则"按钮,展开快速配置面板(剧名/季/集/来源/偏移秒数)
- 来源列表从后端 ALLOWED_SOURCES 动态加载,支持多选
- 修复所有变量编辑模态框内容过长时顶部无法滚动的问题

* 更新文档:DANMU_OFFSET 补充 @来源 语法说明

- README 功能介绍和环境变量表格补充来源指定格式
- .env.example 补充来源语法注释和示例

* 重构弹幕偏移系统:独立 offset-util 模块,支持合并源按来源分别偏移

- 新建 offset-util.js,集中偏移逻辑(解析/匹配/应用)
- parseOffsetRules 启动时解析一次,缓存到 globals.danmuOffsetRules
- resolveOffset 支持多级路径匹配(集>季>剧)、来源特定/all通配/通用规则优先级
- applyOffset 兼容 p/t/progress/timepoint 多种时间字段格式
- 支持来源别名映射(tencent→qq, iqiyi→qiyi, bilibili→bilibili1)
- 合并源流程:对齐后、合并前按来源分别应用偏移
- 单源流程:convertToDanmakuJson 前应用偏移
- 删除 danmu-util.js 中旧的 parseOffset/getOffset/applyDanmuOffset

* 优化偏移配置界面:inline styles 提取到 CSS,来源标签选中状态改用 class 控制
This commit is contained in:
Celestials316 authored and GitHub committed 2026-03-18 23:38:31 +08:00
1 parent 13fe8519f1
commit faa570273f
9 files changed
+418 -69

No files matched your search

+2 -2
View File
@@ -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生效,支持格式:<br> 正常代理:`http://127.0.0.1:7890` <br> 万能反代:`@http://127.0.0.1` <br> 特定反代:`源字段@http://127.0.0.1`,目前支持的字段有:`bahamut,tmdb,bilibili`(bilibili字段会启用阿b的港澳台番剧的搜索与获取)<br> 混合配置/示例:`http://你的代理地址:28233,bahamut@你的巴哈反代地址,tmdb@你的tmdb反代地址,@你的万能反代地址` <br> 优先级:特定反代 > 万能反代 > 正常代理,高优先级覆盖低优先级使用。 <br> (注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,如需使用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`表示不限流 |
+3 -2
View File
@@ -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=
+33 -8
View File
@@ -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<Array>} 合并后的弹幕列表
*/
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);
}
}
+4 -1
View File
@@ -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
+64
View File
@@ -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;
}
`;
+2 -1
View File
@@ -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 等需要来源配置的有效
});
});
+115 -2
View File
@@ -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 = \`
<label>变量值</label>
<textarea id="text-value" placeholder="格式:剧名:秒 或 剧名/S01:秒 或 剧名@来源:秒" rows="\${rows}" class="text-monospace">\${value}</textarea>
<div style="margin-top: 8px;">
<button type="button" class="btn btn-primary btn-sm" id="offset-rule-toggle" onclick="toggleOffsetRulePanel()">
添加规则
</button>
</div>
<div id="offset-rule-panel" class="offset-rule-panel">
<div class="form-help" style="margin: 0 0 8px 0;">季和集不填则对所有季/集生效</div>
<div class="offset-form-row">
<div style="flex: 2; min-width: 100px;">
<label class="offset-label">剧名 *</label>
<input type="text" id="offset-anime" class="offset-input" placeholder="例如: overlord">
</div>
<div style="width: 65px;">
<label class="offset-label">季</label>
<input type="number" id="offset-season" class="offset-input" placeholder="" min="1" max="99">
</div>
<div style="width: 65px;">
<label class="offset-label">集</label>
<input type="number" id="offset-episode" class="offset-input" placeholder="" min="1" max="999">
</div>
<div style="width: 85px;">
<label class="offset-label">偏移秒 *</label>
<input type="number" id="offset-seconds" class="offset-input" placeholder="90">
</div>
</div>
\${offsetSources.length > 0 ? \`
<div style="margin-bottom: 10px;">
<label class="offset-label">来源 (可选,不选则对所有来源生效)</label>
<div id="offset-sources" class="offset-sources">
\${offsetSources.map(src => \`
<div class="offset-source-tag" data-value="\${src}" onclick="toggleOffsetSource(this)">
\${src}
</div>
\`).join('')}
</div>
</div>
\` : ''}
<div class="offset-actions">
<button type="button" class="btn btn-sm" onclick="toggleOffsetRulePanel()">取消</button>
<button type="button" class="btn btn-primary btn-sm" onclick="appendOffsetRule()">确认添加</button>
</div>
</div>
\`;
} else if (isAiApiKey) {
// AI API Key 专用编辑界面
container.innerHTML = \`
<div class="ai-apikey-editor">
@@ -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');
-53
View File
@@ -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字段也更新
};
});
}
+195
View File
@@ -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;
});
}