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:
1 parent
13fe8519f1
commit
faa570273f
9 files changed
+418
-69
No files matched your search
@@ -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
@@ -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=
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
@@ -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 等需要来源配置的有效
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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字段也更新
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user