} 合并后的弹幕列表
*/
-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;
+ });
+}