diff --git a/README.md b/README.md index 0cbe508..372e3e4 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ LogVar 弹幕 API 服务器 - `POST /api/v2/match`:根据关键字匹配动漫,用于自动匹配。(已支持在match接口中通过@语法动态指定平台优先级,如`赴山海 S01E28 @qiyi`;已支持从网盘资源命名,如`无忧渡.S01E01.2160p.WEB-DL.H265.DDP.5.1`中提取 title/season/episode);已支持外语标题匹配,如`Blood.River.S01E05`,需配置环境变量`TITLE_TO_CHINESE`使用;已适配该格式`爱情公寓.ipartment.2009.S03E05.H.265.25fps.mkv`标题;已支持AI自动匹配,需配合AI相关环境变量使用 - `GET /api/v2/search/episodes`:根据关键词搜索所有匹配的剧集信息。 - `GET /api/v2/bangumi/:animeId`:获取指定动漫的详细信息。 - - `GET /api/v2/comment/:commentId?format=json`:获取指定弹幕评论,支持返回相关评论和字符转换。 + - `GET /api/v2/comment/:commentId?format=json&duration=true`:获取指定弹幕评论;当 `duration=true` 且返回 JSON 时,会额外附带 `videoDuration` 字段,优先返回源站时长,拿不到时返回 `0`。 - `GET /api/v2/comment?url=${videoUrl}&format=json`:通过视频URL直接获取弹幕(兼容第三方弹幕服务器格式)。 - `POST /api/v2/segmentcomment?format=json`:通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据。 - `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。 @@ -81,6 +81,7 @@ LogVar 弹幕 API 服务器 - **弹幕时间偏移功能**:支持通过环境变量 `DANMU_OFFSET` 配置弹幕时间偏移,解决弹幕与视频不同步的问题。格式为 `剧名:秒`(全剧偏移)、`剧名/季:秒`(整季偏移)、`剧名/季/集:秒`(单集偏移),多条用逗号分隔。例如:`overlord/S01:90, re-zero/S02:120, re-zero/S02/E03:10`。正数表示弹幕延后(向右),负数表示弹幕提前(向左)。 - **弹幕分片请求**: - `/api/v2/comment` 请求时支持定义 `segmentflag=true` 参数,用于请求弹幕分片列表 + - `/api/v2/comment/:commentId?format=json&duration=true` 可在 JSON 返回体中附带 `videoDuration` - `/api/v2/segmentcomment` 通过comment接口返回体中的Segment类JSON数据获取单独一个分片的弹幕数据 - **UI界面-后台配置管理系统**:支持通过UI执行一些操作(详细见 [UI 系统使用说明](https://github.com/huangxd-/danmu_api/tree/main/danmu_api/ui/README.md) ),包括: - 配置预览 @@ -142,6 +143,7 @@ LogVar 弹幕 API 服务器 - `GET http://{ip}:9321/87654321/api/v2/search/episodes?anime=生万物` - `GET http://{ip}:9321/87654321/api/v2/bangumi/1` - `GET http://{ip}:9321/87654321/api/v2/comment/1?format=json` + - `GET http://{ip}:9321/87654321/api/v2/comment/1?format=json&duration=true` - `GET http://{ip}:9321/87654321/api/v2/comment?url=https://v.qq.com/x/cover/xxx.html&format=json` - `POST http://{ip}:9321/87654321/api/v2/segmentcomment?format=json` (请求体包含segment类JSON数据,示例 `{"type": "qq","segment_start":0,"segment_end":30000,"url":"https://dm.video.qq.com/barrage/segment/j0032ubhl9s/t/v1/0/30000"}` ) - `GET http://{ip}:9321/87654321/api/logs` @@ -355,6 +357,7 @@ API 支持返回 Bilibili 标准 XML 格式的弹幕数据,通过查询参数 **使用示例**: - 获取 JSON 格式:`GET /api/v2/comment/10001` - 获取 XML 格式:`GET /api/v2/comment/10001?format=xml` +- 获取 JSON 并附带视频时长:`GET /api/v2/comment/10001?format=json&duration=true` - 通过 URL 获取弹幕:`GET /api/v2/comment?url=https://v.qq.com/x/cover/xxx.html&format=json` > 注意: diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index d1a31d6..647e810 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -69,6 +69,103 @@ const tmdbSource = new TmdbSource(doubanSource); // 用于聚合请求的去重Map const PENDING_DANMAKU_REQUESTS = new Map(); +function normalizeDurationValue(rawValue) { + const duration = Number(rawValue || 0); + if (!Number.isFinite(duration) || duration <= 0) return 0; + return duration > 6 * 60 * 60 ? duration / 1000 : duration; +} + +function shouldIncludeVideoDuration(queryFormat, includeDuration = false) { + if (!includeDuration) return false; + const format = String(queryFormat || globals.danmuOutputFormat || 'json').toLowerCase(); + return format === 'json'; +} + +function buildDanmuResponse(data, videoDuration = null) { + if (videoDuration === null) return data; + return { videoDuration, ...data }; +} + +function extractDurationFromSegments(segmentResult) { + const explicitDuration = normalizeDurationValue(segmentResult?.duration || segmentResult?.videoDuration || 0); + if (explicitDuration > 0) return explicitDuration; + + const segmentList = Array.isArray(segmentResult?.segmentList) ? segmentResult.segmentList : []; + if (!segmentList.length) return 0; + + let duration = 0; + segmentList.forEach((segment) => { + const normalized = normalizeDurationValue(segment?.segment_end || 0); + if (normalized <= 0) return; + if (normalized > duration) duration = normalized; + }); + + return duration > 0 ? duration : 0; +} + +async function resolveUrlDuration(url) { + if (!/^https?:\/\//i.test(url)) return 0; + + try { + let targetUrl = url; + let segmentResult = null; + + if (targetUrl.includes('.qq.com')) { + segmentResult = await tencentSource.getComments(targetUrl, 'qq', true); + } else if (targetUrl.includes('.iqiyi.com')) { + segmentResult = await iqiyiSource.getComments(targetUrl, 'qiyi', true); + } else if (targetUrl.includes('.mgtv.com')) { + segmentResult = await mangoSource.getComments(targetUrl, 'imgo', true); + } else if (targetUrl.includes('.bilibili.com') || targetUrl.includes('b23.tv')) { + if (targetUrl.includes('b23.tv')) { + targetUrl = await bilibiliSource.resolveB23Link(targetUrl); + } + segmentResult = await bilibiliSource.getComments(targetUrl, 'bilibili1', true); + } else if (targetUrl.includes('.youku.com')) { + segmentResult = await youkuSource.getComments(targetUrl, 'youku', true); + } else if (targetUrl.includes('.miguvideo.com')) { + segmentResult = await miguSource.getComments(targetUrl, 'migu', true); + } else if (targetUrl.includes('.sohu.com')) { + segmentResult = await sohuSource.getComments(targetUrl, 'sohu', true); + } else if (targetUrl.includes('.le.com')) { + segmentResult = await leshiSource.getComments(targetUrl, 'leshi', true); + } else if (targetUrl.includes('.douyin.com') || targetUrl.includes('.ixigua.com')) { + segmentResult = await xiguaSource.getComments(targetUrl, 'xigua', true); + } else if (targetUrl.includes('.mddcloud.com.cn')) { + segmentResult = await maiduiduiSource.getComments(targetUrl, 'maiduidui', true); + } + + return extractDurationFromSegments(segmentResult); + } catch (error) { + log('warn', `[Duration] 获取时长失败: ${error.message}`); + return 0; + } +} + +function extractMergedUrls(url) { + return String(url || '') + .split(MERGE_DELIMITER) + .map((part) => { + const firstColonIndex = part.indexOf(':'); + if (firstColonIndex === -1) return part.trim(); + return part.slice(firstColonIndex + 1).trim(); + }) + .filter(Boolean); +} + +async function resolveMergedDuration(url) { + if (!url) return 0; + + try { + const targetUrls = url.includes(MERGE_DELIMITER) ? extractMergedUrls(url) : [url]; + const durations = await Promise.all(targetUrls.map(resolveUrlDuration)); + return durations.reduce((maxValue, currentValue) => Math.max(maxValue, currentValue || 0), 0); + } catch (error) { + log('warn', `[Duration] 获取时长失败: ${error.message}`); + return 0; + } +} + // 匹配年份函数,优先于季匹配 function matchYear(anime, queryYear) { if (!queryYear) { @@ -1347,12 +1444,13 @@ async function fetchMergedComments(url) { } // Extracted function for GET /api/v2/comment/:commentId -export async function getComment(path, queryFormat, segmentFlag, clientIp) { +export async function getComment(path, queryFormat, segmentFlag, clientIp, includeDuration = false) { const commentId = parseInt(path.split("/").pop()); let animeTitle = findAnimeTitleById(commentId); let url = findUrlById(commentId); let title = findTitleById(commentId); let plat = title ? (title.match(/【(.*?)】/) || [null])[0]?.replace(/[【】]/g, '') : null; + const shouldAttachDuration = shouldIncludeVideoDuration(queryFormat, includeDuration); log("info", "comment url...", url); log("info", "comment title...", title); log("info", "comment platform...", plat); @@ -1365,12 +1463,16 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp) { // 检查弹幕缓存 const cachedComments = getCommentCache(url); if (cachedComments !== null) { - const responseData = { count: cachedComments.length, comments: cachedComments }; + const responseData = buildDanmuResponse( + { count: cachedComments.length, comments: cachedComments }, + shouldAttachDuration ? await resolveMergedDuration(url) : null + ); return formatDanmuResponse(responseData, queryFormat); } log("info", "开始从本地请求弹幕...", url); let danmus = []; + const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null; if (url && url.includes(MERGE_DELIMITER)) { danmus = await fetchMergedComments(url); @@ -1482,12 +1584,15 @@ export async function getComment(path, queryFormat, segmentFlag, clientIp) { } } - const responseData = { count: danmus.length, comments: danmus }; + const responseData = buildDanmuResponse( + { count: danmus.length, comments: danmus }, + durationPromise ? await durationPromise : null + ); return formatDanmuResponse(responseData, queryFormat); } // Extracted function for GET /api/v2/comment?url=xxx -export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) { +export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag, includeDuration = false) { try { // 验证URL参数 if (!videoUrl || typeof videoUrl !== 'string') { @@ -1512,21 +1617,23 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) { log("info", `Processing comment request for URL: ${videoUrl}`); let url = videoUrl; + const shouldAttachDuration = shouldIncludeVideoDuration(queryFormat, includeDuration); // 检查弹幕缓存 const cachedComments = getCommentCache(url); if (cachedComments !== null) { - const responseData = { + const responseData = buildDanmuResponse({ errorCode: 0, success: true, errorMessage: "", count: cachedComments.length, comments: cachedComments - }; + }, shouldAttachDuration ? await resolveMergedDuration(url) : null); return formatDanmuResponse(responseData, queryFormat); } log("info", "开始从本地请求弹幕...", url); let danmus = []; + const durationPromise = shouldAttachDuration ? resolveMergedDuration(url) : null; // 根据URL域名判断平台并获取弹幕 if (url.includes('.qq.com')) { @@ -1568,13 +1675,13 @@ export async function getCommentByUrl(videoUrl, queryFormat, segmentFlag) { setCommentCache(url, danmus); } - const responseData = { + const responseData = buildDanmuResponse({ errorCode: 0, success: true, errorMessage: "", count: danmus.length, comments: danmus - }; + }, durationPromise ? await durationPromise : null); return formatDanmuResponse(responseData, queryFormat); } catch (error) { // 处理异常 diff --git a/danmu_api/models/dandan-model.js b/danmu_api/models/dandan-model.js index 85e029b..9727ed7 100644 --- a/danmu_api/models/dandan-model.js +++ b/danmu_api/models/dandan-model.js @@ -274,15 +274,16 @@ export class Bangumi { // 数据模型:SegmentListResponse // ===================== export class SegmentListResponse { - constructor({ type = "", segmentList = [] } = {}) { + constructor({ type = "", segmentList = [], duration = 0 } = {}) { validateType(type, "string"); validateType(segmentList, "array"); + validateType(duration, "number"); // 将 segmentList 转换为 Segment 实例数组 this.segmentList = segmentList.map(segmentData => Segment.fromJson(segmentData)); // 直接解构并赋值给 this - Object.assign(this, { type }); + Object.assign(this, { type, duration }); } // ---- 静态方法:从 JSON 创建 SegmentListResponse 对象 ---- diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 5c7b817..66cb35f 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -800,7 +800,7 @@ export default class BilibiliSource extends BaseSource { } // 计算视频的分片数量 - const maxLen = (duration > 0) ? (Math.floor(duration / 360) + 1) : 36; + const maxLen = (duration > 0) ? Math.ceil(duration / 360) : 36; log("info", `maxLen: ${maxLen}`); const segmentList = []; @@ -815,13 +815,14 @@ export default class BilibiliSource extends BaseSource { segmentList.push({ "type": "bilibili1", "segment_start": i * 360, - "segment_end": (i + 1) * 360, + "segment_end": duration > 0 ? Math.min((i + 1) * 360, duration) : (i + 1) * 360, "url": danmakuUrl }); } return new SegmentListResponse({ "type": "bilibili1", + "duration": duration > 0 ? duration : 0, "segmentList": segmentList }); } diff --git a/danmu_api/sources/sohu.js b/danmu_api/sources/sohu.js index 455957e..97ce6d3 100644 --- a/danmu_api/sources/sohu.js +++ b/danmu_api/sources/sohu.js @@ -147,51 +147,52 @@ export default class SohuSource extends BaseSource { } } + async getPlaylistData(id) { + const params = { + 'playlistid': id, + 'api_key': "f351515304020cad28c92f70f002261c" + }; + + const headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://tv.sohu.com/' + }; + + const playlistUrl = `https://pl.hd.sohu.com/videolist?${buildQueryString(params)}`; + const response = await httpGet(playlistUrl, { headers, timeout: 15000 }); + + if (!response || !response.data) { + return null; + } + + let data = response.data; + if (typeof data === "string" && data.startsWith('jsonp')) { + const start = data.indexOf('(') + 1; + const end = data.lastIndexOf(')'); + if (start > 0 && end > start) { + data = JSON.parse(data.substring(start, end)); + } else { + log("error", "搜狐视频: 无法解析JSONP响应"); + return null; + } + } else if (typeof data === "string") { + data = JSON.parse(data); + } + + return data; + } + async getEpisodes(id) { try { log("info", `[Sohu] 获取分集列表: media_id=${id}`); - let videosData = null; - - const params = { - 'playlistid': id, - 'api_key': "f351515304020cad28c92f70f002261c" - }; - - const headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', - 'Referer': 'https://tv.sohu.com/' - }; - - const playlistUrl = `https://pl.hd.sohu.com/videolist?${buildQueryString(params)}`; - - const response = await httpGet(playlistUrl, { headers, timeout: 15000 }); - - if (!response || !response.data) { + const data = await this.getPlaylistData(id); + if (!data) { log("info", "[Sohu] 分集响应为空"); return []; } - let data = response.data; - - // 解析JSONP响应 - if (typeof data === "string" && data.startsWith('jsonp')) { - // 提取括号内的JSON - const start = data.indexOf('(') + 1; - const end = data.lastIndexOf(')'); - if (start > 0 && end > start) { - const jsonStr = data.substring(start, end); - data = JSON.parse(jsonStr); - } else { - log("error", "搜狐视频: 无法解析JSONP响应"); - return []; - } - } else if (typeof data === "string") { - data = JSON.parse(data); - } - - // 提取视频列表 - videosData = data.videos || []; + const videosData = data.videos || []; if (!videosData || videosData.length === 0) { log("warning", `搜狐视频: 未找到分集列表 (media_id=${id})`); @@ -302,6 +303,23 @@ export default class SohuSource extends BaseSource { return processSohuAnimes; } + async getEpisodeDuration(aid, vid) { + if (!aid) return 0; + + try { + const data = await this.getPlaylistData(aid); + const videos = Array.isArray(data?.videos) ? data.videos : []; + if (!videos.length) return 0; + + const matchedVideo = videos.find(video => String(video?.vid || '') === String(vid || '')) || (videos.length === 1 ? videos[0] : null); + const duration = Number(matchedVideo?.playLength || 0); + return Number.isFinite(duration) && duration > 0 ? duration : 0; + } catch (error) { + log("warn", `[Sohu] 获取真实时长失败: ${error.message}`); + return 0; + } + } + // 提取vid和aid的公共函数 async extractVidAndAid(id) { let vid; @@ -430,13 +448,13 @@ export default class SohuSource extends BaseSource { // 解析 episode_id const { vid, aid } = await this.extractVidAndAid(id); - // 搜狐视频的弹幕分段逻辑,使用固定的最大时间 - const maxTime = 10800; + const duration = await this.getEpisodeDuration(aid, vid); + const maxTime = duration > 0 ? Math.ceil(duration) : 10800; const segmentDuration = 300; // 300秒一段 const segments = []; for (let start = 0; start < maxTime; start += segmentDuration) { - const end = start + segmentDuration; + const end = Math.min(start + segmentDuration, maxTime); segments.push({ "type": "sohu", "segment_start": start, @@ -447,6 +465,7 @@ export default class SohuSource extends BaseSource { return new SegmentListResponse({ "type": "sohu", + "duration": duration > 0 ? duration : 0, "segmentList": segments }); } diff --git a/danmu_api/ui/README.md b/danmu_api/ui/README.md index fa3c377..523be1e 100644 --- a/danmu_api/ui/README.md +++ b/danmu_api/ui/README.md @@ -84,7 +84,7 @@ UI 系统需要通过在 URL 中添加 TOKEN 来访问,以确保安全性: - 弹幕热力图:按时间段可视化弹幕密度分布 - 弹幕列表:支持全部/滚动/顶部/底部过滤,显示颜色和类型标签,懒加载每次100条 - 导出功能:支持导出JSON和XML格式弹幕文件 -- 异常时间处理:使用P99分位数剔除异常时间点 +- 时长处理:优先请求 `/api/v2/comment/:commentId?format=json&duration=true` 返回的 `videoDuration`,没有真实时长时再回退到尾部裁剪算法 - 视图导航优化:搜索结果/剧集详情/弹幕详情逐级切换,带返回按钮和加载动画 ### 4. 推送弹幕 diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index 2726c06..3a9ac04 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -42,6 +42,7 @@ const apiConfigs = { params: [ { name: 'commentId', label: '弹幕ID', type: 'text', required: true, placeholder: '示例: 10009' }, { name: 'format', label: '格式', type: 'select', required: false, placeholder: '可选: json或xml', options: ['json', 'xml'] }, + { name: 'duration', label: '附带时长', type: 'select', required: false, placeholder: '可选: true或false', options: ['true', 'false'] }, { name: 'segmentflag', label: '分片标志', type: 'select', required: false, placeholder: '可选: true或false', options: ['true', 'false'] } ] }, @@ -65,7 +66,8 @@ let danmuTestState = { displayedCount: 0, pageSize: 100, currentEpisodeId: null, - currentTitle: '' + currentTitle: '', + currentDuration: 0 }; // 初始化接口调试界面 @@ -105,6 +107,31 @@ function attachEnterEventToParams() { }, 100); } +function updateCommentDurationFieldVisibility() { + const apiSelect = document.getElementById('api-select'); + const durationGroup = document.querySelector('#params-form [data-param-name="duration"]'); + const durationInput = document.getElementById('param-duration'); + const formatInput = document.getElementById('param-format'); + + if (!durationGroup) return; + + const isCommentApi = apiSelect && apiSelect.value === 'getComment'; + const formatValue = formatInput ? formatInput.value.toLowerCase() : ''; + const shouldShowDuration = isCommentApi && (formatValue === '' || formatValue === 'json'); + + durationGroup.style.display = shouldShowDuration ? '' : 'none'; + if (!shouldShowDuration && durationInput) { + durationInput.value = ''; + } +} + +function bindCommentDurationFieldVisibility() { + const formatInput = document.getElementById('param-format'); + if (!formatInput) return; + formatInput.addEventListener('change', updateCommentDurationFieldVisibility); + updateCommentDurationFieldVisibility(); +} + // 处理参数输入框的回车事件 function handleParamInputEnter(event) { if (event.key === 'Enter') { @@ -145,7 +172,7 @@ function loadApiParams() { optionsHtml += param.options.map(opt => \`\`).join(''); } return \` -