feat: 支持更多类型的弹幕输出 (#382)
* feat: 添加基于 @dan-uni/dan-any 的多弹幕格式输出支持,并优化弹幕数据格式化响应 * feat: 更新 README 关于支持多种弹幕格式输出的内容,并添加环境变量配置选项
This commit is contained in:
1 parent
a8147a9811
commit
57e693f3d5
6 files changed
+82
-31
No files matched your search
@@ -62,9 +62,9 @@ LogVar 弹幕 API 服务器
|
||||
- `GET /danmaku/api/v2/fongmi/danmaku?name={name}&episode={episode}`:兼容FengMi影视api短路径。
|
||||
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
|
||||
- `GET /api/cache/animes`:获取最近的 animes 缓存。
|
||||
- **弹幕格式输出**:支持 JSON 和 XML 两种格式输出,通过以下方式配置:
|
||||
- 环境变量:`DANMU_OUTPUT_FORMAT=json|xml`(默认:json)
|
||||
- 查询参数:`?format=xml` 或 `?format=json`(优先级最高)
|
||||
- **弹幕格式输出**:支持 JSON 和 XML 及 [@dan-uni/dan-any](https://github.com/ani-uni/dan-any)支持的全部输出格式 输出,通过以下方式配置:
|
||||
- 环境变量:`DANMU_OUTPUT_FORMAT=json|xml|artplayer.json|baha.json|bili.xml|danuni.json|danuni.binpb|ddplay.json|dplayer.json|vod.json`(默认:json)
|
||||
- 查询参数:`?format=xml` 或 `?format=json` ...(优先级最高)
|
||||
- 优先级:查询参数 > 环境变量 > 默认值
|
||||
- 示例:`GET /api/v2/comment/10001?format=xml` 返回 XML 格式弹幕
|
||||
- **XML 格式说明**:完全遵循 Bilibili 标准格式,8字段标准弹幕属性
|
||||
@@ -453,7 +453,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
| CONVERT_COLOR | 【可选】弹幕转换颜色配置,默认为`default`(不转换),`white` 将所有非白色的弹幕颜色转换为纯白色,`color` 将所有白色弹幕转换为随机颜色(包含白色),可选值:`default`、`white`、`color` |
|
||||
| COLOR_POOL | 【可选】自定义颜色池(`CONVERT_COLOR`为`color`时生效),不配置使用默认颜色池(白、红、橙、黄、绿、青、蓝、紫、粉),格式:十进制颜色值逗号分隔,例如:`16711680,65280,255,16776960` |
|
||||
| LIKE_SWITCH | 【可选】弹幕点赞数显示开关,默认为`true`(开启),开启后会在弹幕内容后显示点赞数标记,≥5 才显示,避免低赞干扰 |
|
||||
| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式),支持通过查询参数`?format=xml`或`?format=json`覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 |
|
||||
| DANMU_OUTPUT_FORMAT | 【可选】弹幕输出格式,默认为`json`,可选值:`json`(JSON格式)、`xml`(XML格式)及所有`@dan-uni/dan-any`支持的输出格式,支持通过查询参数`?format=xml`或`?format=json`等覆盖此设置,优先级:查询参数 > 环境变量 > 默认值 |
|
||||
| DANMU_SIMPLIFIED_TRADITIONAL | 【可选】弹幕简繁体转换设置:default(默认不转换)、simplified(繁转简)、traditional(简转繁) |
|
||||
| DANMU_OFFSET | 【可选】弹幕时间偏移配置,用于解决弹幕与视频不同步的问题。格式:剧名:秒(全剧偏移)或 剧名/季:秒(整季偏移)或 剧名/季/集:秒(单集偏移),支持指定来源:剧名@来源:秒 或 剧名/季@来源1&来源2:秒(不指定来源则对所有来源生效),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02@bilibili:120, re-zero/S02/E03@dandan&bilibili:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。支持百分比模式,在路径/来源末尾添加 `%`,例如:`东方/S03/E02@tencent%:11`,按 `原时间 * (视频时长 + 偏移秒数) / 视频时长` 计算新的弹幕发送时间。 |
|
||||
| UI_THEME | 【可选】管理界面默认主题,默认为 `ocean`。浏览器中选择的主题会保存在本地并优先使用。可选值:`ocean`、`forest`、`graphite`、`berry`、`monochrome`、`sunset`、`aurora`、`lavender`、`mist`、`terminal` |
|
||||
@@ -761,3 +761,4 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数
|
||||
### 📈项目 Star 数增长趋势
|
||||
#### Star History
|
||||
[](https://www.star-history.com/#huangxd-/danmu_api&Date)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 环境变量管理模块
|
||||
* 提供获取和设置环境变量的函数,支持 Cloudflare Workers 和 Node.js
|
||||
*/
|
||||
import { danAnyFormats } from '../utils/dan-any.js';
|
||||
import { parseOffsetRules } from '../utils/offset-util.js';
|
||||
|
||||
export class Envs {
|
||||
@@ -652,7 +653,7 @@ export class Envs {
|
||||
'CONVERT_TOP_BOTTOM_TO_SCROLL': { category: 'danmu', type: 'boolean', description: '顶部/底部弹幕转换为浮动弹幕' },
|
||||
'CONVERT_COLOR': { category: 'danmu', type: 'select', options: ['default', 'white', 'color'], description: '弹幕转换颜色配置' },
|
||||
'COLOR_POOL': { category: 'danmu', type: 'text', description: '自定义颜色池(CONVERT_COLOR为color时生效),不配置使用默认颜色池,格式:十进制颜色值逗号分隔' },
|
||||
'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml'], description: '弹幕输出格式,默认json' },
|
||||
'DANMU_OUTPUT_FORMAT': { category: 'danmu', type: 'select', options: ['json', 'xml', ...danAnyFormats], 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: '弹幕点赞数显示开关,默认开启' },
|
||||
'HONGGUO_MERGE_ALL_EPISODES': { category: 'danmu', type: 'boolean', description: '红果短剧合并全集弹幕,默认关闭' },
|
||||
@@ -724,7 +725,7 @@ export class Envs {
|
||||
convertTopBottomToScroll: this.get('CONVERT_TOP_BOTTOM_TO_SCROLL', false, 'boolean'), // 顶部/底部弹幕转换为浮动弹幕配置(默认 false,禁用转换)
|
||||
convertColor: this.get('CONVERT_COLOR', 'default', 'string'), // 弹幕转换颜色配置,支持 default、white、color(默认 default,禁用转换)
|
||||
colorPool: this.get('COLOR_POOL', '16777215,16777215,16777215,16777215,16777215,16777215,16777215,16777215,16744319,16752762,16774799,9498256,8388564,8900346,14204888,16758465', 'string'), // 自定义颜色池,CONVERT_COLOR为color时生效
|
||||
danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string'), // 弹幕输出格式配置(默认 json,可选值:json, xml)
|
||||
danmuOutputFormat: this.get('DANMU_OUTPUT_FORMAT', 'json', 'string'), // 弹幕输出格式配置(默认 json,可选值:json, xml, ...danAnyFormats)
|
||||
strictTitleMatch: this.get('STRICT_TITLE_MATCH', false, 'boolean'), // 严格标题匹配模式配置(默认 false,宽松模糊匹配)
|
||||
titleToChinese: this.get('TITLE_TO_CHINESE', false, 'boolean'), // 外语标题转换中文开关
|
||||
animeTitleSimplified: this.get('ANIME_TITLE_SIMPLIFIED', false, 'boolean'), // 搜索的剧名标题自动繁转简
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ArtplayerMetadata, BahaMetadata, BiliXmlMetadata, DanuniJsonMetadata, DanuniPbMetadata, DdplayMetadata, DplayerMetadata, VodMetadata } from '@dan-uni/dan-any/adapters';
|
||||
|
||||
export const danAnyFormats = [
|
||||
ArtplayerMetadata.type,
|
||||
BahaMetadata.type,
|
||||
BiliXmlMetadata.type,
|
||||
DanuniJsonMetadata.type,
|
||||
DanuniPbMetadata.type,
|
||||
DdplayMetadata.type,
|
||||
DplayerMetadata.type,
|
||||
VodMetadata.type
|
||||
];
|
||||
@@ -1,7 +1,9 @@
|
||||
import { globals } from '../configs/globals.js';
|
||||
import { log } from './log-util.js'
|
||||
import { jsonResponse, xmlResponse } from "./http-util.js";
|
||||
import { binResponse, jsonResponse, xmlResponse } from "./http-util.js";
|
||||
import { traditionalized } from './zh-util.js';
|
||||
import { UniDB } from "@dan-uni/dan-any/core/main/pure";
|
||||
import { ArtplayerMetadata, ArtplayerTransformer, BahaMetadata, BahaTransformer, BiliXmlMetadata, BiliXmlTransformerConfigurator, DanuniJsonMetadata, DanuniJsonTransformerConfigurator, DanuniPbMetadata, DanuniPbTransformer, DdplayAdapter, DdplayMetadata, DdplayTransformer, DplayerMetadata, DplayerTransformer, VodMetadata, VodTransformer } from '@dan-uni/dan-any/adapters';
|
||||
|
||||
// =====================
|
||||
// danmu处理相关函数
|
||||
@@ -28,7 +30,7 @@ export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) {
|
||||
const groupedByTime = filteredDanmus.reduce((acc, danmu) => {
|
||||
// 获取时间:优先使用 t 字段,如果没有则使用 p 的第一个值
|
||||
const time = danmu.t !== undefined ? danmu.t : parseFloat(danmu.p.split(',')[0]);
|
||||
|
||||
|
||||
// 确定分组键:n=0时使用精确时间(保留2位小数),否则使用分钟索引
|
||||
const groupKey = n === 0 ? time.toFixed(2) : Math.floor(time / (n * 60));
|
||||
|
||||
@@ -64,14 +66,14 @@ export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) {
|
||||
acc[message].earliestT = Math.min(acc[message].earliestT, danmu.t);
|
||||
// 合并like字段,如果是undefined则视为0
|
||||
acc[message].like += (danmu.like !== undefined ? danmu.like : 0);
|
||||
|
||||
|
||||
// 提取当前弹幕的来源并加入集合中,建立弹幕内容与平台的精确映射
|
||||
if (danmu.p) {
|
||||
const match = danmu.p.match(/\[([^\]]*)\]$/);
|
||||
if (match && match[1]) {
|
||||
match[1].split(/[&&]/).forEach(s => {
|
||||
if (s.trim()) acc[message].sources.add(s.trim());
|
||||
});
|
||||
match[1].split(/[&&]/).forEach(s => {
|
||||
if (s.trim()) acc[message].sources.add(s.trim());
|
||||
});
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
@@ -80,11 +82,11 @@ export function groupDanmusByMinute(filteredDanmus, n, isMultiSource = false) {
|
||||
// 转换为结果格式
|
||||
return Object.keys(groupedByMessage).map(message => {
|
||||
const data = groupedByMessage[message];
|
||||
|
||||
|
||||
// 以当前这句弹幕实际跨越的独立平台数作为除数,进行局部精准降噪,保留单平台内真实的重复计数
|
||||
let localSourceCount = Math.max(1, data.sources.size);
|
||||
let displayCount = Math.round(data.count / localSourceCount);
|
||||
|
||||
|
||||
if (displayCount < 1) displayCount = 1;
|
||||
|
||||
// 将收集到的所有真实独立来源重新拼装回 p 属性标签中
|
||||
@@ -270,15 +272,15 @@ export function convertToDanmakuJson(contents, platform) {
|
||||
|
||||
// 优先使用弹幕自带的 _sourceLabel(应对合并工具),其次是外部传入的宏观 platform
|
||||
let currentPlatform = item._sourceLabel || platform;
|
||||
|
||||
|
||||
// 如果存在实时拉取的副源标签,安全追加
|
||||
if (item.realTimeSource && !currentPlatform.includes(item.realTimeSource)) {
|
||||
currentPlatform = `${currentPlatform}&${item.realTimeSource}`;
|
||||
currentPlatform = `${currentPlatform}&${item.realTimeSource}`;
|
||||
}
|
||||
|
||||
// 在组装字符串时,顺带通过符号检测判定当前是否为多源组合数据
|
||||
if (!isMultiSource && /[&&]/.test(currentPlatform)) {
|
||||
isMultiSource = true;
|
||||
isMultiSource = true;
|
||||
}
|
||||
|
||||
attributes = [
|
||||
@@ -488,7 +490,10 @@ function escapeXmlText(str) {
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
// 根据格式参数返回弹幕数据(JSON 或 XML)
|
||||
const tmp_da_db = new UniDB()
|
||||
const tmp_da_udb = tmp_da_db.init()
|
||||
|
||||
// 根据格式参数返回弹幕数据
|
||||
export function formatDanmuResponse(danmuData, queryFormat) {
|
||||
// 确定最终使用的格式:查询参数 > 环境变量 > 默认值
|
||||
let format = queryFormat || globals.danmuOutputFormat;
|
||||
@@ -496,6 +501,7 @@ export function formatDanmuResponse(danmuData, queryFormat) {
|
||||
|
||||
log("info", `[system] [danmu] [format] Using format: ${format}`);
|
||||
|
||||
// 兼容旧格式转换
|
||||
if (format === 'xml') {
|
||||
try {
|
||||
const xmlData = convertDanmuToXml(danmuData);
|
||||
@@ -505,7 +511,19 @@ export function formatDanmuResponse(danmuData, queryFormat) {
|
||||
// 转换失败时回退到 JSON
|
||||
return jsonResponse(danmuData);
|
||||
}
|
||||
}
|
||||
} else if (format === 'json') return jsonResponse(danmuData);
|
||||
|
||||
// 转换为 @dan-uni/dan-any 支持的格式
|
||||
const chunk = tmp_da_udb.makeChunk({ tmp: true })
|
||||
chunk.import(DdplayAdapter(danmuData))
|
||||
if (format === VodMetadata.type) return jsonResponse(chunk.export(VodTransformer))
|
||||
else if (format === BahaMetadata.type) return jsonResponse(chunk.export(BahaTransformer))
|
||||
else if (format === DdplayMetadata.type) return jsonResponse(chunk.export(DdplayTransformer))
|
||||
else if (format === BiliXmlMetadata.type) return xmlResponse(chunk.export(BiliXmlTransformerConfigurator()))
|
||||
else if (format === DplayerMetadata.type) return jsonResponse(chunk.export(DplayerTransformer))
|
||||
else if (format === DanuniPbMetadata.type) return binResponse(chunk.export(DanuniPbTransformer), 'danuni.binpb')
|
||||
else if (format === ArtplayerMetadata.type) return jsonResponse(chunk.export(ArtplayerTransformer))
|
||||
else if (format === DanuniJsonMetadata.type) return jsonResponse(chunk.export(DanuniJsonTransformerConfigurator()))
|
||||
|
||||
// 默认返回 JSON
|
||||
return jsonResponse(danmuData);
|
||||
|
||||
@@ -33,11 +33,11 @@ export function toLogSourceName(sourceKey) {
|
||||
* @returns {Function} 监听器清理闭包
|
||||
*/
|
||||
function linkSignal(externalSignal, internalController) {
|
||||
if (!externalSignal) return () => {};
|
||||
if (!externalSignal) return () => { };
|
||||
|
||||
if (externalSignal.aborted) {
|
||||
internalController.abort();
|
||||
return () => {};
|
||||
return () => { };
|
||||
}
|
||||
|
||||
const abortHandler = () => {
|
||||
@@ -518,7 +518,7 @@ export async function getPageTitle(url) {
|
||||
export function jsonResponse(data, status = 200) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
status,
|
||||
headers: {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
@@ -534,13 +534,24 @@ export function xmlResponse(data, status = 200) {
|
||||
// 直接返回 XML 字符串作为 Response 的 body
|
||||
return new Response(data, {
|
||||
status,
|
||||
headers: {
|
||||
headers: {
|
||||
"Content-Type": "application/xml",
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function binResponse(data, filename = "data.bin", status = 200) {
|
||||
return new Response(data, {
|
||||
status,
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
"Access-Control-Allow-Origin": "*"
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildQueryString(params, encode = true) {
|
||||
const encodeFn = encode ? encodeURIComponent : (v) => v;
|
||||
let queryString = '';
|
||||
@@ -698,8 +709,8 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
|
||||
const text = await response.text();
|
||||
clearTimeout(timeoutId);
|
||||
if (checkCallback && !checkCallback(text.slice(0, SNIFF_LIMIT))) {
|
||||
log("info", `[${currentSource}] [流式请求] 检测到无效数据(回退模式),丢弃结果`);
|
||||
return null;
|
||||
log("info", `[${currentSource}] [流式请求] 检测到无效数据(回退模式),丢弃结果`);
|
||||
return null;
|
||||
}
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
@@ -713,7 +724,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
|
||||
let stopChecking = false; // 标记是否停止检查
|
||||
|
||||
// 流式读取循环
|
||||
while(true) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
@@ -724,7 +735,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
|
||||
// 1. 数据嗅探逻辑 (仅在前 SNIFF_LIMIT 范围内执行)
|
||||
if (!stopChecking && checkCallback) {
|
||||
// 累积文本
|
||||
const chunkText = new TextDecoder("utf-8").decode(value, {stream: true});
|
||||
const chunkText = new TextDecoder("utf-8").decode(value, { stream: true });
|
||||
checkBuffer += chunkText;
|
||||
|
||||
// 执行回调检查
|
||||
@@ -738,8 +749,8 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
|
||||
|
||||
// 如果缓冲区超过限制
|
||||
if (receivedLength > SNIFF_LIMIT) {
|
||||
stopChecking = true;
|
||||
checkBuffer = null; // 释放缓冲区内存
|
||||
stopChecking = true;
|
||||
checkBuffer = null; // 释放缓冲区内存
|
||||
}
|
||||
}
|
||||
|
||||
@@ -753,7 +764,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
|
||||
// 2. 拼接完整数据
|
||||
let chunksAll = new Uint8Array(receivedLength);
|
||||
let position = 0;
|
||||
for(let chunk of chunks) {
|
||||
for (let chunk of chunks) {
|
||||
chunksAll.set(chunk, position);
|
||||
position += chunk.length;
|
||||
}
|
||||
@@ -769,7 +780,7 @@ export async function httpGetWithStreamCheck(url, options = {}, checkCallback) {
|
||||
const currentSource = sourceLogContext.getStore() || "system";
|
||||
clearTimeout(timeoutId);
|
||||
if (error.name === 'AbortError') {
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
log("error", `[${currentSource}] [流式请求] 失败: ${error.message}`);
|
||||
return null;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@dan-uni/dan-any": "^2.3.9",
|
||||
"brotli": "^1.3.3",
|
||||
"chokidar": "^4.0.3",
|
||||
"dotenv": "^16.4.7",
|
||||
@@ -21,5 +22,12 @@
|
||||
"opencc-js": "^1.4.1",
|
||||
"pako": "^2.1.0",
|
||||
"redis": "^5.11.0"
|
||||
},
|
||||
"devEngines": {
|
||||
"packageManager": {
|
||||
"name": "pnpm",
|
||||
"version": "11.15.1",
|
||||
"onFail": "download"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user