koishi-plugin-kkk 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # 更新日志
2
2
 
3
+ ## 3.0.1
4
+
5
+ ### 在线播放:声音不再等服务器
6
+
7
+ - **默认不在服务器上合视频和声音了**:播放页会同时放画面和声音,由浏览器自己对好(原来要等 ffmpeg 在服务器上合完才能开始放,等得久)。
8
+ - **下载按钮分成了三个**:「下载画面」(只有画面的那份,和以前一样)、「下载声音」(单独把声音存下来)、「合成后下载」(连声音一起下载,点下去服务器才去合并,合好再给你;合过一次会记住,再点马上就好)。
9
+ - 页面上的静音按钮现在同时管画面和声音;拖动进度条时声音也会跟着对齐。
10
+
3
11
  ## 3.0.0
4
12
 
5
13
  > 这一版主要两件事:**让它快**、**该发出去的东西真的发出去**。
@@ -324,7 +324,7 @@ class Bilibili extends utils_1.Base {
324
324
  }
325
325
  else {
326
326
  /**
327
- * 免登录直链分支:体积取自 html5 播放接口的 \`durl[0].size\`。
327
+ * 免登录直链分支:体积取自 html5 播放接口的 `durl[0].size`。
328
328
  * 这个接口会偶发拿不到 durl(风控、字段变化、超时),原来这里直接下标取值,
329
329
  * 一旦为空就抛 TypeError,**整条解析直接失败**(用户侧表现就是「提示开始解析,然后没下文」)。
330
330
  * 体积只是展示信息,取不到就按 0 处理,不要拖垮解析。
@@ -1319,6 +1319,12 @@ class Bilibili extends utils_1.Base {
1319
1319
  * - 本地文件(登录态合成/烧录的产物、免登录时提前下好的直链);
1320
1320
  * - 没有下载产物时保持 null(例如体积超限根本没下)。
1321
1321
  */
1322
+ /**
1323
+ * 已经准备好的视频(见 prepareVideo)。
1324
+ *
1325
+ * `audioPath` 只有**在线播放 + 音视频分离(B站)**时才有:表示音轨单独存了一份,
1326
+ * 播放页会同时播这两个文件(默认不合成,用户要求)。
1327
+ */
1322
1328
  preparedVideo = null;
1323
1329
  /**
1324
1330
  * 下载视频(含合成音轨、烧录弹幕),**不发送**。
@@ -1421,23 +1427,52 @@ class Bilibili extends utils_1.Base {
1421
1427
  }));
1422
1428
  sourcePath = resultPath;
1423
1429
  }
1430
+ else if ((0, player_1.isOnlinePlayerRequest)()) {
1431
+ /**
1432
+ * **在线播放:默认不合成**(用户要求)。
1433
+ *
1434
+ * B站的音视频是分离的,以前这里必定调一次 ffmpeg 合成(几分钟的视频也要几秒到几十秒,
1435
+ * 低配机器更久)。改成:画面和声音**各自留一份**,播放页用 `<video muted>` + `<audio>`
1436
+ * 同时播;只有用户在播放页点「服务器合并后下载」时才按需合成(见 player/server.ts)。
1437
+ */
1438
+ success = true;
1439
+ sourcePath = bmp4.filepath;
1440
+ node_karin_1.logger.mark('[在线播放] 跳过音视频合成:画面与声音分开存,浏览器端同时播放');
1441
+ }
1424
1442
  else {
1425
1443
  success = await (0, utils_1.mergeVideoAudio)(bmp4.filepath, bmp3.filepath, resultPath);
1426
1444
  sourcePath = resultPath;
1427
1445
  }
1428
1446
  if (success) {
1429
- const filePath = utils_1.Common.tempDri.video + `${Config_1.Config.app.removeCache ? 'tmp_' + Date.now() : this.downloadfilename}.mp4`;
1430
- node_fs_1.default.renameSync(sourcePath, filePath);
1431
- node_karin_1.logger.mark(`视频文件重命名完成: ${sourcePath.split('/').pop()} -> ${filePath.split('/').pop()}`);
1432
- node_karin_1.logger.mark('正在尝试删除缓存文件');
1433
- if (node_fs_1.default.existsSync(bmp4.filepath))
1434
- await utils_1.Common.removeFile(bmp4.filepath, true);
1435
- if (bmp3 && node_fs_1.default.existsSync(bmp3.filepath))
1436
- await utils_1.Common.removeFile(bmp3.filepath, true);
1437
- const stats = node_fs_1.default.statSync(filePath);
1438
- const fileSizeInMB = Number((stats.size / (1024 * 1024)).toFixed(2));
1439
- // 本地合成的没有视频直链,交给 sendPreparedVideo 上传
1440
- this.preparedVideo = { filepath: filePath, totalBytes: fileSizeInMB, originTitle: this.downloadfilename };
1447
+ /**
1448
+ * 在线播放(音视频分离)时**不要重命名** —— 画面那个文件原样留着,
1449
+ * 「audioPath」指向单独的音轨文件,两个都由 publishOnlinePlayer 搬进会话目录。
1450
+ */
1451
+ const separateAudio = (0, player_1.isOnlinePlayerRequest)() && !!bmp3 && sourcePath === bmp4.filepath;
1452
+ if (separateAudio) {
1453
+ const videoStats = node_fs_1.default.statSync(bmp4.filepath);
1454
+ this.preparedVideo = {
1455
+ filepath: bmp4.filepath,
1456
+ totalBytes: Number((videoStats.size / (1024 * 1024)).toFixed(2)),
1457
+ originTitle: this.downloadfilename,
1458
+ audioPath: bmp3.filepath
1459
+ };
1460
+ node_karin_1.logger.mark('[在线播放] 已就绪:画面 + 声音分开,交给播放页同时播放');
1461
+ }
1462
+ else {
1463
+ const filePath = utils_1.Common.tempDri.video + `${Config_1.Config.app.removeCache ? 'tmp_' + Date.now() : this.downloadfilename}.mp4`;
1464
+ node_fs_1.default.renameSync(sourcePath, filePath);
1465
+ node_karin_1.logger.mark(`视频文件重命名完成: ${sourcePath.split('/').pop()} -> ${filePath.split('/').pop()}`);
1466
+ node_karin_1.logger.mark('正在尝试删除缓存文件');
1467
+ if (node_fs_1.default.existsSync(bmp4.filepath))
1468
+ await utils_1.Common.removeFile(bmp4.filepath, true);
1469
+ if (bmp3 && node_fs_1.default.existsSync(bmp3.filepath))
1470
+ await utils_1.Common.removeFile(bmp3.filepath, true);
1471
+ const stats = node_fs_1.default.statSync(filePath);
1472
+ const fileSizeInMB = Number((stats.size / (1024 * 1024)).toFixed(2));
1473
+ // 本地合成的没有视频直链,交给 sendPreparedVideo 上传
1474
+ this.preparedVideo = { filepath: filePath, totalBytes: fileSizeInMB, originTitle: this.downloadfilename };
1475
+ }
1441
1476
  }
1442
1477
  else {
1443
1478
  await utils_1.Common.removeFile(bmp4.filepath, true);
@@ -1528,7 +1563,7 @@ class Bilibili extends utils_1.Base {
1528
1563
  this.preparedVideo = null;
1529
1564
  if (!prepared)
1530
1565
  return false;
1531
- const { filepath, totalBytes, originTitle, videoUrl } = prepared;
1566
+ const { filepath, totalBytes, originTitle, videoUrl, audioPath } = prepared;
1532
1567
  /**
1533
1568
  * 在线播放模式:不烧录、也不上传,直接把下好的视频登记成播放会话,
1534
1569
  * 回一条公网链接(弹幕存下来给播放页用)。
@@ -1538,6 +1573,8 @@ class Bilibili extends utils_1.Base {
1538
1573
  if ((0, player_1.isOnlinePlayerRequest)()) {
1539
1574
  const published = await (0, player_1.publishOnlinePlayer)(this.e, {
1540
1575
  videoPath: filepath,
1576
+ // 分离音轨:一起交给播放页(默认不合成,浏览器里同时播)
1577
+ audioPath,
1541
1578
  title: originTitle || this.downloadfilename || this.workInfo?.title,
1542
1579
  platform: 'bilibili',
1543
1580
  danmaku: this.danmakuList,
@@ -526,7 +526,11 @@ async function publishOnlinePlayer(e, input) {
526
526
  * 体积上限:超过就不做在线播放,回一句说明并返回 false ——
527
527
  * 调用方拿到 false 会退回「直接发送视频文件」,用户不会什么都没有。
528
528
  */
529
- const sizeMB = Number(node_fs_1.default.statSync(input.videoPath).size) / 1024 / 1024;
529
+ /** 有独立音轨时按「画面 + 声音」的总和算,免得两条加起来把磁盘塞爆 */
530
+ const audioBytes = input.audioPath && node_fs_1.default.existsSync(input.audioPath)
531
+ ? Number(node_fs_1.default.statSync(input.audioPath).size) || 0
532
+ : 0;
533
+ const sizeMB = (Number(node_fs_1.default.statSync(input.videoPath).size) + audioBytes) / 1024 / 1024;
530
534
  const limitMB = await resolvePlayerSizeLimitMB();
531
535
  if (limitMB > 0 && sizeMB > limitMB) {
532
536
  /**
@@ -547,6 +551,7 @@ async function publishOnlinePlayer(e, input) {
547
551
  const localOnly = !hasPublicBaseUrl();
548
552
  const session = (0, store_1.registerPlayerSession)({
549
553
  videoPath: input.videoPath,
554
+ audioPath: input.audioPath,
550
555
  title: input.title,
551
556
  platform: input.platform,
552
557
  danmaku,
@@ -226,6 +226,9 @@ const PLAYER_STYLE = [
226
226
  'background:var(--pink);color:#fff;font-size:14px;font-weight:600;cursor:pointer;}',
227
227
  '.dlbtn svg{font-size:20px}',
228
228
  '.dlbtn:hover{background:#ff8aac}',
229
+ /** 「合成后下载」是主按钮(另外两个只是分别下载原始流),稍微区分一下 */
230
+ '.dlbtn.main{background:#fb7299;color:#fff}',
231
+ '.dlbtn.main:hover{background:#ff8aac}',
229
232
  '.dlbtn:focus-visible{outline:2px solid #fff;outline-offset:2px}',
230
233
  '.dlhint{color:var(--muted);font-size:12px;}',
231
234
  /* 没配公网地址时的提示条:用户打不开得知道是「没配域名」,不是「插件坏了」 */
@@ -287,6 +290,8 @@ function renderPlayerPage(info) {
287
290
  const platform = escapeHtml(platformLabel(info.platform));
288
291
  const expire = formatExpire(info.expireAt);
289
292
  const danmakuCount = Number(info.danmakuCount) || 0;
293
+ /** 有没有**单独的音轨**(B站这类音视频分离的):有就 <video muted> + <audio> 同时播 */
294
+ const hasAudio = typeof info.audioPath === 'string' && info.audioPath.length > 0;
290
295
  const duration = formatDuration(info.durationSeconds);
291
296
  const script = PLAYER_SCRIPT.split('__KKK_TOKEN__').join(token);
292
297
  const stats = [
@@ -338,7 +343,12 @@ function renderPlayerPage(info) {
338
343
  + ' </div>\n'
339
344
  + ' <div class="player">\n'
340
345
  + ' <div class="video-wrap" id="videoWrap">\n'
341
- + ' <video id="video" src="/kkk/player/' + token + '/video" preload="metadata" playsinline webkit-playsinline></video>\n'
346
+ /**
347
+ * 音视频分离时:视频**静音**播画面,声音交给下面这个 <audio> 一起播
348
+ * (用户要求:默认不在服务器合成,浏览器端同时播放即可)。
349
+ */
350
+ + ' <video id="video" src="/kkk/player/' + token + '/video" preload="metadata" playsinline webkit-playsinline' + (hasAudio ? ' muted' : '') + '></video>\n'
351
+ + (hasAudio ? ' <audio id="audioTrack" src="/kkk/player/' + token + '/audio" preload="auto"></audio>\n' : '')
342
352
  + ' <canvas id="danmaku"></canvas>\n'
343
353
  + ' <div id="notice" class="notice" hidden></div>\n'
344
354
  + ' <button type="button" class="bigplay" id="bigPlay" aria-label="播放">' + ICON.play + '</button>\n'
@@ -384,9 +394,23 @@ function renderPlayerPage(info) {
384
394
  * 放在播放器下面单独一条 —— 作品信息可能缺(那时不会渲染操作按钮排),下载入口不能跟着一起消失。
385
395
  */
386
396
  + ' <div class="downbar">'
387
- + '<a class="dlbtn" id="downloadBtn" href="/kkk/player/' + token + '/video?download=1" download>'
388
- + ICON.download + '<span>下载视频</span></a>'
389
- + '<span class="dlhint">保存到本机(原画质,不重新编码)</span></div>\n'
397
+ + (hasAudio
398
+ /**
399
+ * 音视频分离:给两个「分别下载」+ 一个「服务器合成后下载」。
400
+ * 分别是秒下(就是两个原文件),合成那条第一次点要等一两秒(服务器跑一次 ffmpeg -c copy)。
401
+ */
402
+ ? '<a class="dlbtn" href="/kkk/player/' + token + '/video?download=1" download>'
403
+ + ICON.download + '<span>下载画面</span></a>'
404
+ + '<a class="dlbtn" href="/kkk/player/' + token + '/audio?download=1" download>'
405
+ + ICON.download + '<span>下载声音</span></a>'
406
+ + '<a class="dlbtn main" href="/kkk/player/' + token + '/merged?download=1" download>'
407
+ + ICON.download + '<span>合成后下载</span></a>'
408
+ + '<span class="dlhint">画面与声音是分开的两条流:前两个直接下载,最后一个由服务器合成成一个视频(第一次要等一两秒);'
409
+ + '想在播放器里看就直接播放,声音会自动跟上</span>'
410
+ : '<a class="dlbtn" id="downloadBtn" href="/kkk/player/' + token + '/video?download=1" download>'
411
+ + ICON.download + '<span>下载视频</span></a>'
412
+ + '<span class="dlhint">保存到本机(原画质,不重新编码)</span>')
413
+ + '</div>\n'
390
414
  + linkRow + '\n'
391
415
  + (actions ? ' <div class="actions">' + actions + '</div>\n' : '')
392
416
  + (info.localOnly
@@ -579,6 +603,14 @@ const PLAYER_SCRIPT = [
579
603
  " var notice = document.getElementById('notice')",
580
604
  " var video = document.getElementById('video')",
581
605
  " var canvas = document.getElementById('danmaku')",
606
+ " /**",
607
+ " * 单独的音轨(B站这类音视频分离的)。有它的时候:",
608
+ " * - 视频元素是 muted 的(只出画面),声音由这个 <audio> 出;",
609
+ " * - 播放 / 暂停 / 拖动进度都要**带着它一起**,否则声音会跟画面错开。",
610
+ " */",
611
+ " var audioTrack = document.getElementById('audioTrack')",
612
+ " /** 音量控制作用在谁身上:有独立音轨就是音轨,否则是视频自己 */",
613
+ " var soundEl = audioTrack || video",
582
614
  " var toggle = document.getElementById('dmOn')",
583
615
  " var countEl = document.getElementById('dmCount')",
584
616
  " var settingsBtn = document.getElementById('dmSettingsBtn')",
@@ -763,6 +795,8 @@ const PLAYER_SCRIPT = [
763
795
  " cursor++",
764
796
  " guard++",
765
797
  " }",
798
+ " // 音画同步:偏差超过 0.25 秒就拉回来(长时间播放 / 卡顿后必须对一次)",
799
+ " if (audioTrack && !video.paused && !video.ended && !video.seeking) alignAudio()",
766
800
  " draw(dt, now)",
767
801
  " }",
768
802
  "",
@@ -967,12 +1001,34 @@ const PLAYER_SCRIPT = [
967
1001
  " })",
968
1002
  " }",
969
1003
  "",
1004
+ " /** 把音轨拉到和画面同一个位置(拖动 / 卡顿时对表) */",
1005
+ " function alignAudio () {",
1006
+ " if (!audioTrack) return",
1007
+ " try {",
1008
+ " if (Math.abs((audioTrack.currentTime || 0) - video.currentTime) > 0.25) audioTrack.currentTime = video.currentTime",
1009
+ " } catch (error) { /* 还没加载好就等下一帧 */ }",
1010
+ " }",
1011
+ "",
1012
+ " function playAudio () {",
1013
+ " if (!audioTrack) return",
1014
+ " alignAudio()",
1015
+ " var started = audioTrack.play()",
1016
+ " if (started && started.catch) started.catch(function () { /* 画面已经在放,声音起不来就先算了 */ })",
1017
+ " }",
1018
+ "",
1019
+ " function pauseAudio () {",
1020
+ " if (!audioTrack) return",
1021
+ " try { audioTrack.pause() } catch (error) { /* 忽略 */ }",
1022
+ " }",
1023
+ "",
970
1024
  " function togglePlay () {",
971
1025
  " if (video.paused || video.ended) {",
972
1026
  " var started = video.play()",
973
1027
  " if (started && started.catch) started.catch(function () { showNotice('浏览器拦住了自动播放,点一下画面中间的播放按钮就行') })",
1028
+ " playAudio()",
974
1029
  " } else {",
975
1030
  " video.pause()",
1031
+ " pauseAudio()",
976
1032
  " }",
977
1033
  " }",
978
1034
  "",
@@ -984,22 +1040,34 @@ const PLAYER_SCRIPT = [
984
1040
  " }",
985
1041
  "",
986
1042
  " function syncMute () {",
987
- " if (muteBtn) muteBtn.classList.toggle('is-muted', video.muted || video.volume === 0)",
1043
+ " if (muteBtn) muteBtn.classList.toggle('is-muted', soundEl.muted || soundEl.volume === 0)",
988
1044
  " }",
989
1045
  "",
990
- " video.addEventListener('play', syncPlayState)",
991
- " video.addEventListener('pause', syncPlayState)",
992
- " video.addEventListener('ended', syncPlayState)",
1046
+ " video.addEventListener('play', function () { syncPlayState(); playAudio() })",
1047
+ " video.addEventListener('pause', function () { syncPlayState(); pauseAudio() })",
1048
+ " video.addEventListener('ended', function () { syncPlayState(); pauseAudio() })",
993
1049
  " video.addEventListener('timeupdate', drawProgress)",
994
1050
  " video.addEventListener('progress', drawProgress)",
995
1051
  " video.addEventListener('durationchange', drawProgress)",
996
1052
  " video.addEventListener('loadedmetadata', drawProgress)",
997
1053
  " video.addEventListener('volumechange', syncMute)",
1054
+ " // 拖动进度(含键盘 / 快进):画面跳完马上把音轨对齐,不然会差出好几秒",
1055
+ " video.addEventListener('seeked', alignAudio)",
1056
+ " if (audioTrack) {",
1057
+ " audioTrack.addEventListener('volumechange', syncMute)",
1058
+ " // 音轨自己卡住 / 被系统暂停时,跟着画面走",
1059
+ " audioTrack.addEventListener('pause', function () { if (!video.paused && !video.ended) playAudio() })",
1060
+ " audioTrack.addEventListener('error', function () { showNotice('声音加载失败,画面继续播放(可以在下面单独下载声音)') })",
1061
+ " }",
998
1062
  " if (playBtn) playBtn.addEventListener('click', togglePlay)",
999
1063
  " if (bigPlay) bigPlay.addEventListener('click', togglePlay)",
1000
1064
  " // 点画面中间也能播放 / 暂停(弹幕 canvas 是 pointer-events:none,点它等于点在 video 上)",
1001
1065
  " video.addEventListener('click', togglePlay)",
1002
- " if (muteBtn) muteBtn.addEventListener('click', function () { video.muted = !video.muted; syncMute() })",
1066
+ " if (muteBtn) {",
1067
+ " // 有独立音轨时,静音 / 取消静音作用在音轨上(视频一直是 muted 的)",
1068
+ " if (audioTrack) soundEl.muted = true",
1069
+ " muteBtn.addEventListener('click', function () { soundEl.muted = !soundEl.muted; syncMute() })",
1070
+ " }",
1003
1071
  "",
1004
1072
  " function isFullscreen () { return !!(document.fullscreenElement || document.webkitFullscreenElement) }",
1005
1073
  "",
@@ -1,4 +1,37 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
@@ -14,7 +47,9 @@ exports.registerPlayerRoutes = registerPlayerRoutes;
14
47
  * 三条路由(都挂在自己拼的 `/kkk/player` 前缀下,和配置面板的 /kkk 互不干扰):
15
48
  * GET /kkk/player/:token 播放页(HTML)
16
49
  * GET /kkk/player/:token/video 视频本体,支持 Range(拖进度条靠它);`?download=1` = 下载
17
- * GET /kkk/player/:token/download 同上,等价于 `/video?download=1`(带 Content-Disposition)
50
+ * GET /kkk/player/:token/audio 单独的音轨(B站这种音视频分离的);`?download=1` = 下载音频
51
+ * GET /kkk/player/:token/merged **按需**用 ffmpeg 合成音视频后下载/播放(第一次会慢一两秒)
52
+ * GET /kkk/player/:token/download 等价于 `/video?download=1`(带 Content-Disposition)
18
53
  * GET /kkk/player/:token/danmaku 弹幕 JSON
19
54
  *
20
55
  * 两种落地方式:
@@ -27,6 +62,7 @@ exports.registerPlayerRoutes = registerPlayerRoutes;
27
62
  */
28
63
  const node_fs_1 = __importDefault(require("node:fs"));
29
64
  const node_http_1 = __importDefault(require("node:http"));
65
+ const node_path_1 = __importDefault(require("node:path"));
30
66
  const node_karin_1 = require("../compat/node-karin.js");
31
67
  const page_1 = require("./page");
32
68
  const store_1 = require("./store");
@@ -101,13 +137,13 @@ function sanitizeDownloadName(title) {
101
137
  * 两个文件名都给:`filename=` 用 ASCII 兜底名(老客户端 / 头编码限制),
102
138
  * `filename*=` 用 RFC 5987 的 UTF-8 形式放原名(现代浏览器优先用它,中文名不会变成下划线)。
103
139
  */
104
- function downloadDisposition(title) {
105
- const full = sanitizeDownloadName(title) + '.mp4';
140
+ function downloadDisposition(title, ext = '.mp4') {
141
+ const full = sanitizeDownloadName(title) + ext;
106
142
  // 纯中文标题会变成一排下划线(对老客户端毫无意义),这种情况直接给个通用名
107
143
  const asciiRaw = full.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_');
108
144
  // 只在**扩展名之前**数有效字符:纯中文标题会变成「_____.mp4」,那 3 个字母来自 .mp4 不能算数
109
- const asciiBase = asciiRaw.replace(/\.(mp4|mkv|webm|mov)$/i, '');
110
- const ascii = /[A-Za-z0-9]/.test(asciiBase) ? asciiRaw : 'video.mp4';
145
+ const asciiBase = asciiRaw.replace(/\.(mp4|mkv|webm|mov|m4a|aac|mp3)$/i, '');
146
+ const ascii = /[A-Za-z0-9]/.test(asciiBase) ? asciiRaw : 'video' + ext;
111
147
  const encoded = encodeURIComponent(full).replace(/['()*]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase());
112
148
  return 'attachment; filename="' + ascii + '"; filename*=UTF-8\'\'' + encoded;
113
149
  }
@@ -115,29 +151,34 @@ function downloadDisposition(title) {
115
151
  * 视频响应:支持 Range,未过期时是 video/mp4。
116
152
  * @param download 为 true 时带上 `Content-Disposition: attachment`(浏览器直接下载),Range 照旧支持
117
153
  */
118
- function videoResponse(token, range, head = false, download = false) {
119
- const video = (0, store_1.resolvePlayerVideo)(token);
120
- if (!video)
121
- return notFound(false);
154
+ /**
155
+ * 通用文件响应:支持 Range。
156
+ *
157
+ * @param file 文件路径与大小
158
+ * @param contentType 响应的 Content-Type(视频 / 音轨 / 合并后的视频各不同)
159
+ * @param ext 下载时的扩展名(决定 Content-Disposition 里的文件名)
160
+ * @param download 是否带 \`Content-Disposition: attachment\`
161
+ */
162
+ function fileResponse(token, file, contentType, range, head = false, download = false, ext = '.mp4') {
122
163
  const base = {
123
- 'Content-Type': 'video/mp4',
164
+ 'Content-Type': contentType,
124
165
  'Accept-Ranges': 'bytes',
125
166
  'Cache-Control': 'no-store'
126
167
  };
127
168
  // 下载:文件名取会话标题(清洗过),Range 行为不变(浏览器正常下载时也不会带 Range)
128
169
  if (download)
129
- base['Content-Disposition'] = downloadDisposition((0, store_1.getPlayerSession)(token)?.title);
130
- const parsed = parseRange(range, video.size);
170
+ base['Content-Disposition'] = downloadDisposition((0, store_1.getPlayerSession)(token)?.title, ext);
171
+ const parsed = parseRange(range, file.size);
131
172
  if (parsed === 'invalid') {
132
173
  return {
133
174
  status: 416,
134
- headers: { ...base, 'Content-Range': 'bytes */' + video.size },
175
+ headers: { ...base, 'Content-Range': 'bytes */' + file.size },
135
176
  body: Buffer.from('请求的范围无效')
136
177
  };
137
178
  }
138
179
  if (!parsed) {
139
- base['Content-Length'] = String(video.size);
140
- return { status: 200, headers: base, file: head ? undefined : { path: video.path, start: 0, end: video.size - 1 } };
180
+ base['Content-Length'] = String(file.size);
181
+ return { status: 200, headers: base, file: head ? undefined : { path: file.path, start: 0, end: file.size - 1 } };
141
182
  }
142
183
  const length = parsed.end - parsed.start + 1;
143
184
  return {
@@ -145,11 +186,78 @@ function videoResponse(token, range, head = false, download = false) {
145
186
  headers: {
146
187
  ...base,
147
188
  'Content-Length': String(length),
148
- 'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + video.size
189
+ 'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + file.size
149
190
  },
150
- file: head ? undefined : { path: video.path, start: parsed.start, end: parsed.end }
191
+ file: head ? undefined : { path: file.path, start: parsed.start, end: parsed.end }
151
192
  };
152
193
  }
194
+ /** 视频本体(分离音轨时这里只有画面) */
195
+ function videoResponse(token, range, head = false, download = false) {
196
+ const video = (0, store_1.resolvePlayerVideo)(token);
197
+ if (!video)
198
+ return notFound(false);
199
+ return fileResponse(token, video, 'video/mp4', range, head, download, '.mp4');
200
+ }
201
+ /** 单独的音轨(B站这类音视频分离的):\`<audio>\` 直接播它,\`?download=1\` 下载 m4a */
202
+ function audioResponse(token, range, head = false, download = false) {
203
+ const audio = (0, store_1.resolvePlayerAudio)(token);
204
+ if (!audio)
205
+ return notFound(false);
206
+ return fileResponse(token, audio, 'audio/mp4', range, head, download, '.m4a');
207
+ }
208
+ /**
209
+ * **按需合成**:把画面和音轨用 ffmpeg 合成一条 mp4(\`-c copy\`,不重新编码,通常一两秒)。
210
+ *
211
+ * 为什么要按需:用户要求「默认不合并音频、浏览器里同时播放就行」——
212
+ * 解析时不再花时间合成,只有点了「服务器合并后下载」才做,而且做完会留在会话目录里,
213
+ * 同一条链接再点就是秒回(到期随会话一起删)。
214
+ *
215
+ * @returns 合成好的文件;没有独立音轨 / ffmpeg 失败时返回 null(路由回 404)
216
+ */
217
+ async function ensureMerged(token) {
218
+ const cached = (0, store_1.resolvePlayerMerged)(token);
219
+ if (cached)
220
+ return cached;
221
+ const video = (0, store_1.resolvePlayerVideo)(token);
222
+ const audio = (0, store_1.resolvePlayerAudio)(token);
223
+ if (!video || !audio)
224
+ return null;
225
+ const session = (0, store_1.getPlayerSession)(token);
226
+ if (!session)
227
+ return null;
228
+ const target = node_path_1.default.join(session.dir, 'merged.mp4');
229
+ const started = Date.now();
230
+ try {
231
+ const { spawn } = await Promise.resolve().then(() => __importStar(require('node:child_process')));
232
+ const { resolveFfmpegBin } = await Promise.resolve().then(() => __importStar(require('../compat/node-karin.js')));
233
+ const bin = resolveFfmpegBin();
234
+ await new Promise((resolve, reject) => {
235
+ const proc = spawn(bin, [
236
+ '-y',
237
+ '-i', video.path,
238
+ '-i', audio.path,
239
+ '-c', 'copy',
240
+ '-movflags', '+faststart',
241
+ target
242
+ ], { stdio: 'ignore' });
243
+ proc.on('error', reject);
244
+ proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error('ffmpeg exit ' + code))));
245
+ });
246
+ const stat = node_fs_1.default.statSync(target);
247
+ (0, store_1.markPlayerMerged)(token, 'merged.mp4');
248
+ node_karin_1.logger.mark('[在线播放] 已按需合成音视频(' + (stat.size / 1024 / 1024).toFixed(1) + 'MB,'
249
+ + (Date.now() - started) + 'ms):' + token);
250
+ return { path: target, size: stat.size };
251
+ }
252
+ catch (error) {
253
+ node_karin_1.logger.warn('[在线播放] 合成音视频失败(' + (Date.now() - started) + 'ms): ' + String(error?.message ?? error));
254
+ try {
255
+ node_fs_1.default.rmSync(target, { force: true });
256
+ }
257
+ catch { /* 忽略 */ }
258
+ return null;
259
+ }
260
+ }
153
261
  /**
154
262
  * 封面图。
155
263
  *
@@ -213,6 +321,19 @@ async function handlePlayerRequest(request) {
213
321
  }
214
322
  if (action === 'video')
215
323
  return videoResponse(token, request.range, method === 'HEAD', wantDownload);
324
+ // 独立音轨(B站音视频分离时才有):播放页的 <audio> 播它,?download=1 下载 m4a
325
+ if (action === 'audio')
326
+ return audioResponse(token, request.range, method === 'HEAD', wantDownload);
327
+ /**
328
+ * 按需合成后下载 / 播放:第一次会跑一次 ffmpeg -c copy(一两秒),之后走缓存。
329
+ * 合成失败(没有独立音轨 / 没有 ffmpeg)回 404,页面上的按钮也就不该点得动。
330
+ */
331
+ if (action === 'merged') {
332
+ const merged = await ensureMerged(token);
333
+ if (!merged)
334
+ return notFound(false);
335
+ return fileResponse(token, merged, 'video/mp4', request.range, method === 'HEAD', wantDownload, '.mp4');
336
+ }
216
337
  // 独立路由:`/kkk/player/<token>/download` 与 `/video?download=1` 完全等价
217
338
  if (action === 'download')
218
339
  return videoResponse(token, request.range, method === 'HEAD', true);
@@ -46,6 +46,9 @@ exports.getPlayerSession = getPlayerSession;
46
46
  exports.readPlayerDanmaku = readPlayerDanmaku;
47
47
  exports.resolvePlayerCover = resolvePlayerCover;
48
48
  exports.resolvePlayerVideo = resolvePlayerVideo;
49
+ exports.resolvePlayerAudio = resolvePlayerAudio;
50
+ exports.markPlayerMerged = markPlayerMerged;
51
+ exports.resolvePlayerMerged = resolvePlayerMerged;
49
52
  exports.deletePlayerSession = deletePlayerSession;
50
53
  exports.sweepExpiredPlayers = sweepExpiredPlayers;
51
54
  exports.listPlayerSessions = listPlayerSessions;
@@ -138,6 +141,9 @@ function normalizeSession(raw) {
138
141
  platform: String(raw.platform ?? ''),
139
142
  dir: typeof raw.dir === 'string' && raw.dir ? raw.dir : node_path_1.default.dirname(filePath),
140
143
  filePath,
144
+ audioPath: typeof raw.audioPath === 'string' && raw.audioPath ? raw.audioPath : undefined,
145
+ audioSizeBytes: Number.isFinite(Number(raw.audioSizeBytes)) ? Number(raw.audioSizeBytes) : undefined,
146
+ merged: typeof raw.merged === 'string' && raw.merged ? raw.merged : undefined,
141
147
  sizeBytes: Number(raw.sizeBytes) || 0,
142
148
  danmakuCount: Number(raw.danmakuCount) || 0,
143
149
  createdAt: Number(raw.createdAt) || Date.now(),
@@ -244,6 +250,27 @@ function registerPlayerSession(input) {
244
250
  node_fs_1.default.copyFileSync(source, target);
245
251
  node_fs_1.default.rmSync(source, { force: true });
246
252
  }
253
+ /**
254
+ * 单独的音轨:也**搬**进会话目录(和视频同一套逻辑:同分区改名、跨分区复制 + 删源)。
255
+ * 搬不动就当作「没有独立音轨」,不影响视频本身能播。
256
+ */
257
+ let audioTarget;
258
+ if (input.audioPath && node_fs_1.default.existsSync(input.audioPath)) {
259
+ const candidate = node_path_1.default.join(dir, 'audio.m4a');
260
+ try {
261
+ try {
262
+ node_fs_1.default.renameSync(input.audioPath, candidate);
263
+ }
264
+ catch {
265
+ node_fs_1.default.copyFileSync(input.audioPath, candidate);
266
+ node_fs_1.default.rmSync(input.audioPath, { force: true });
267
+ }
268
+ audioTarget = candidate;
269
+ }
270
+ catch (error) {
271
+ node_karin_1.logger.warn('[在线播放] 音轨文件搬进会话目录失败(将只播视频流): ' + String(error?.message ?? error));
272
+ }
273
+ }
247
274
  const danmaku = Array.isArray(input.danmaku) ? input.danmaku : [];
248
275
  node_fs_1.default.writeFileSync(node_path_1.default.join(dir, 'danmaku.json'), JSON.stringify({ total: danmaku.length, items: danmaku }));
249
276
  // 封面:复制进会话目录,页面用同源地址取(不引外链,断网/内网也能看)
@@ -268,6 +295,8 @@ function registerPlayerSession(input) {
268
295
  dir,
269
296
  filePath: target,
270
297
  sizeBytes: Number(node_fs_1.default.statSync(target).size) || 0,
298
+ audioPath: audioTarget,
299
+ audioSizeBytes: audioTarget ? (Number(node_fs_1.default.statSync(audioTarget).size) || 0) : undefined,
271
300
  danmakuCount: danmaku.length,
272
301
  createdAt: now,
273
302
  expireAt: now + normalizeExpireMinutes(input.expireMinutes) * 60 * 1000,
@@ -367,6 +396,50 @@ function resolvePlayerVideo(token) {
367
396
  return null;
368
397
  }
369
398
  }
399
+ /**
400
+ * 取单独的音轨文件(没有独立音轨就返回 null,路由回 404)。
401
+ *
402
+ * 独立音轨 = 解析时没让服务器合成,播放页用 `<audio>` 和视频一起播(见 PlayerSession.audioPath)。
403
+ */
404
+ function resolvePlayerAudio(token) {
405
+ const session = getPlayerSession(token);
406
+ const audioPath = session?.audioPath;
407
+ if (!audioPath)
408
+ return null;
409
+ try {
410
+ const stat = node_fs_1.default.statSync(audioPath);
411
+ if (!stat.isFile())
412
+ return null;
413
+ return { path: audioPath, size: stat.size };
414
+ }
415
+ catch {
416
+ return null;
417
+ }
418
+ }
419
+ /** 记住「按需合成的文件」文件名(它会留在会话目录里,到期一起清掉) */
420
+ function markPlayerMerged(token, fileName) {
421
+ const session = sessions.get(String(token));
422
+ if (!session)
423
+ return;
424
+ session.merged = fileName;
425
+ persistIndex();
426
+ }
427
+ /** 已经合成好的合并文件路径(没有就 null) */
428
+ function resolvePlayerMerged(token) {
429
+ const session = getPlayerSession(token);
430
+ if (!session?.merged)
431
+ return null;
432
+ try {
433
+ const file = node_path_1.default.join(session.dir, session.merged);
434
+ const stat = node_fs_1.default.statSync(file);
435
+ if (!stat.isFile())
436
+ return null;
437
+ return { path: file, size: stat.size };
438
+ }
439
+ catch {
440
+ return null;
441
+ }
442
+ }
370
443
  /** 删掉一个会话的文件:先按 Common.removeFile 删视频,再把整个会话目录清掉 */
371
444
  async function removePlayerFiles(session) {
372
445
  await removeFileQuietly(session.filePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "koishi-plugin-kkk",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "抖音 / B站 / 快手 / 小红书 视频解析与动态推送,带 QQ 交互面板与弹幕烧录(karin-plugin-kkk 的 Koishi 移植版)",
5
5
  "main": "lib/index.js",
6
6
  "typings": "lib/index.d.ts",
@@ -397,7 +397,12 @@ setTimeout(async () => {
397
397
  if (matched) {
398
398
  fs.writeFileSync(scriptFile, matched[1])
399
399
  try {
400
- require('node:child_process').execFileSync(process.execPath, ['--check', scriptFile], { stdio: 'pipe' })
400
+ /**
401
+ * `stdio: 'ignore'` 而不是 'pipe':受限环境(沙箱)里**管道是禁的**,
402
+ * `stdio: 'pipe'` 会直接 EPERM,把一条好脚本误判成语法错误;
403
+ * 这里只需要退出码,不需要子进程输出。
404
+ */
405
+ require('node:child_process').execFileSync(process.execPath, ['--check', scriptFile], { stdio: 'ignore' })
401
406
  syntaxOk = true
402
407
  } catch (error) {
403
408
  console.log(' ' + String(error?.stderr ?? error?.message ?? error).slice(0, 200))
@@ -898,11 +903,16 @@ setTimeout(async () => {
898
903
  * 门控表达式是 patch-webui 生成到前端包里的,这里直接数产物里的出现次数。
899
904
  */
900
905
  const playerOffMarker = 'Q(e,[' + BT + 'qq' + BT + ',' + BT + 'playerEnabled' + BT + '],!0)===!1'
901
- check('WebUI 里「在线播放器总开关」关掉时组内 6 个字段全部变灰(开关 2 个 + 输入框 4 个)',
902
- !!bundleText && bundleText.split(playerOffMarker).length - 1 === 6,
906
+ /**
907
+ * 组内字段数:**7 个**(开关 2 个 + 文本框 5 个)。
908
+ * 2026-09 加了「强制在线播放的适配器」(文本框,也在这一组里)之后,
909
+ * 这里从 6/4 变成 7/5 —— 断言跟着更新,别把新增的合法字段当成漏门控。
910
+ */
911
+ check('WebUI 里「在线播放器总开关」关掉时组内 7 个字段全部变灰(开关 2 个 + 输入框 5 个)',
912
+ !!bundleText && bundleText.split(playerOffMarker).length - 1 === 7,
903
913
  '门控表达式 ' + (bundleText.split(playerOffMarker).length - 1) + ' 处')
904
- check('其中文本框/数字框用的是 options.disabled(4 个)',
905
- !!bundleText && bundleText.split('disabled:' + playerOffMarker).length - 1 === 4,
914
+ check('其中文本框/数字框用的是 options.disabled(5 个)',
915
+ !!bundleText && bundleText.split('disabled:' + playerOffMarker).length - 1 === 5,
906
916
  'disabled: 形式 ' + (bundleText.split('disabled:' + playerOffMarker).length - 1) + ' 处')
907
917
  check('总开关自己不带这条门控(它只受「强制不烧录弹幕」锁)',
908
918
  !!bundleText && bundleText.split(BT + 'playerEnabled' + BT + '],!0)===!1,').length - 1 === 0)
@@ -355,7 +355,7 @@ export class Bilibili extends Base {
355
355
  videoSize = await getvideosize(correctList.videoList[0].base_url, audioUrl, infoData.data.data.bvid)
356
356
  } else {
357
357
  /**
358
- * 免登录直链分支:体积取自 html5 播放接口的 \`durl[0].size\`。
358
+ * 免登录直链分支:体积取自 html5 播放接口的 `durl[0].size`。
359
359
  * 这个接口会偶发拿不到 durl(风控、字段变化、超时),原来这里直接下标取值,
360
360
  * 一旦为空就抛 TypeError,**整条解析直接失败**(用户侧表现就是「提示开始解析,然后没下文」)。
361
361
  * 体积只是展示信息,取不到就按 0 处理,不要拖垮解析。
@@ -1470,7 +1470,13 @@ export class Bilibili extends Base {
1470
1470
  * - 本地文件(登录态合成/烧录的产物、免登录时提前下好的直链);
1471
1471
  * - 没有下载产物时保持 null(例如体积超限根本没下)。
1472
1472
  */
1473
- protected preparedVideo: { filepath: string; totalBytes: number; originTitle: string; videoUrl?: string } | null = null
1473
+ /**
1474
+ * 已经准备好的视频(见 prepareVideo)。
1475
+ *
1476
+ * `audioPath` 只有**在线播放 + 音视频分离(B站)**时才有:表示音轨单独存了一份,
1477
+ * 播放页会同时播这两个文件(默认不合成,用户要求)。
1478
+ */
1479
+ protected preparedVideo: { filepath: string; totalBytes: number; originTitle: string; videoUrl?: string; audioPath?: string } | null = null
1474
1480
 
1475
1481
  /**
1476
1482
  * 下载视频(含合成音轨、烧录弹幕),**不发送**。
@@ -1601,23 +1607,50 @@ export class Bilibili extends Base {
1601
1607
  })
1602
1608
  )
1603
1609
  sourcePath = resultPath
1610
+ } else if (isOnlinePlayerRequest()) {
1611
+ /**
1612
+ * **在线播放:默认不合成**(用户要求)。
1613
+ *
1614
+ * B站的音视频是分离的,以前这里必定调一次 ffmpeg 合成(几分钟的视频也要几秒到几十秒,
1615
+ * 低配机器更久)。改成:画面和声音**各自留一份**,播放页用 `<video muted>` + `<audio>`
1616
+ * 同时播;只有用户在播放页点「服务器合并后下载」时才按需合成(见 player/server.ts)。
1617
+ */
1618
+ success = true
1619
+ sourcePath = bmp4.filepath
1620
+ logger.mark('[在线播放] 跳过音视频合成:画面与声音分开存,浏览器端同时播放')
1604
1621
  } else {
1605
1622
  success = await mergeVideoAudio(bmp4.filepath, bmp3.filepath, resultPath)
1606
1623
  sourcePath = resultPath
1607
1624
  }
1608
1625
 
1609
1626
  if (success) {
1610
- const filePath = Common.tempDri.video + `${Config.app.removeCache ? 'tmp_' + Date.now() : this.downloadfilename}.mp4`
1611
- fs.renameSync(sourcePath, filePath)
1612
- logger.mark(`视频文件重命名完成: ${sourcePath.split('/').pop()} -> ${filePath.split('/').pop()}`)
1613
- logger.mark('正在尝试删除缓存文件')
1614
- if (fs.existsSync(bmp4.filepath)) await Common.removeFile(bmp4.filepath, true)
1615
- if (bmp3 && fs.existsSync(bmp3.filepath)) await Common.removeFile(bmp3.filepath, true)
1616
-
1617
- const stats = fs.statSync(filePath)
1618
- const fileSizeInMB = Number((stats.size / (1024 * 1024)).toFixed(2))
1619
- // 本地合成的没有视频直链,交给 sendPreparedVideo 上传
1620
- this.preparedVideo = { filepath: filePath, totalBytes: fileSizeInMB, originTitle: this.downloadfilename }
1627
+ /**
1628
+ * 在线播放(音视频分离)时**不要重命名** —— 画面那个文件原样留着,
1629
+ * 「audioPath」指向单独的音轨文件,两个都由 publishOnlinePlayer 搬进会话目录。
1630
+ */
1631
+ const separateAudio = isOnlinePlayerRequest() && !!bmp3 && sourcePath === bmp4.filepath
1632
+ if (separateAudio) {
1633
+ const videoStats = fs.statSync(bmp4.filepath)
1634
+ this.preparedVideo = {
1635
+ filepath: bmp4.filepath,
1636
+ totalBytes: Number((videoStats.size / (1024 * 1024)).toFixed(2)),
1637
+ originTitle: this.downloadfilename,
1638
+ audioPath: bmp3!.filepath
1639
+ }
1640
+ logger.mark('[在线播放] 已就绪:画面 + 声音分开,交给播放页同时播放')
1641
+ } else {
1642
+ const filePath = Common.tempDri.video + `${Config.app.removeCache ? 'tmp_' + Date.now() : this.downloadfilename}.mp4`
1643
+ fs.renameSync(sourcePath, filePath)
1644
+ logger.mark(`视频文件重命名完成: ${sourcePath.split('/').pop()} -> ${filePath.split('/').pop()}`)
1645
+ logger.mark('正在尝试删除缓存文件')
1646
+ if (fs.existsSync(bmp4.filepath)) await Common.removeFile(bmp4.filepath, true)
1647
+ if (bmp3 && fs.existsSync(bmp3.filepath)) await Common.removeFile(bmp3.filepath, true)
1648
+
1649
+ const stats = fs.statSync(filePath)
1650
+ const fileSizeInMB = Number((stats.size / (1024 * 1024)).toFixed(2))
1651
+ // 本地合成的没有视频直链,交给 sendPreparedVideo 上传
1652
+ this.preparedVideo = { filepath: filePath, totalBytes: fileSizeInMB, originTitle: this.downloadfilename }
1653
+ }
1621
1654
  } else {
1622
1655
  await Common.removeFile(bmp4.filepath, true)
1623
1656
  if (bmp3) await Common.removeFile(bmp3.filepath, true)
@@ -1707,7 +1740,7 @@ export class Bilibili extends Base {
1707
1740
  const prepared = this.preparedVideo
1708
1741
  this.preparedVideo = null
1709
1742
  if (!prepared) return false
1710
- const { filepath, totalBytes, originTitle, videoUrl } = prepared
1743
+ const { filepath, totalBytes, originTitle, videoUrl, audioPath } = prepared
1711
1744
  /**
1712
1745
  * 在线播放模式:不烧录、也不上传,直接把下好的视频登记成播放会话,
1713
1746
  * 回一条公网链接(弹幕存下来给播放页用)。
@@ -1717,6 +1750,8 @@ export class Bilibili extends Base {
1717
1750
  if (isOnlinePlayerRequest()) {
1718
1751
  const published = await publishOnlinePlayer(this.e, {
1719
1752
  videoPath: filepath,
1753
+ // 分离音轨:一起交给播放页(默认不合成,浏览器里同时播)
1754
+ audioPath,
1720
1755
  title: originTitle || this.downloadfilename || this.workInfo?.title,
1721
1756
  platform: 'bilibili',
1722
1757
  danmaku: this.danmakuList,
@@ -449,6 +449,11 @@ const TIP_PREPARING = '下载完成,正在准备在线播放…'
449
449
  */
450
450
  export async function publishOnlinePlayer (e: any, input: {
451
451
  videoPath: string
452
+ /**
453
+ * 单独的音轨文件(可选):B站这类音视频分离的流,**默认不再用 ffmpeg 合成**,
454
+ * 两份一起交给播放页,浏览器里同时播(用户要求)。点「服务器合并后下载」时才按需合成。
455
+ */
456
+ audioPath?: string
452
457
  title?: string
453
458
  platform?: string
454
459
  danmaku?: any
@@ -478,7 +483,11 @@ export async function publishOnlinePlayer (e: any, input: {
478
483
  * 体积上限:超过就不做在线播放,回一句说明并返回 false ——
479
484
  * 调用方拿到 false 会退回「直接发送视频文件」,用户不会什么都没有。
480
485
  */
481
- const sizeMB = Number(fs.statSync(input.videoPath).size) / 1024 / 1024
486
+ /** 有独立音轨时按「画面 + 声音」的总和算,免得两条加起来把磁盘塞爆 */
487
+ const audioBytes = input.audioPath && fs.existsSync(input.audioPath)
488
+ ? Number(fs.statSync(input.audioPath).size) || 0
489
+ : 0
490
+ const sizeMB = (Number(fs.statSync(input.videoPath).size) + audioBytes) / 1024 / 1024
482
491
  const limitMB = await resolvePlayerSizeLimitMB()
483
492
  if (limitMB > 0 && sizeMB > limitMB) {
484
493
  /**
@@ -499,6 +508,7 @@ export async function publishOnlinePlayer (e: any, input: {
499
508
  const localOnly = !hasPublicBaseUrl()
500
509
  const session = registerPlayerSession({
501
510
  videoPath: input.videoPath,
511
+ audioPath: input.audioPath,
502
512
  title: input.title,
503
513
  platform: input.platform,
504
514
  danmaku,
@@ -247,6 +247,9 @@ const PLAYER_STYLE = [
247
247
  'background:var(--pink);color:#fff;font-size:14px;font-weight:600;cursor:pointer;}',
248
248
  '.dlbtn svg{font-size:20px}',
249
249
  '.dlbtn:hover{background:#ff8aac}',
250
+ /** 「合成后下载」是主按钮(另外两个只是分别下载原始流),稍微区分一下 */
251
+ '.dlbtn.main{background:#fb7299;color:#fff}',
252
+ '.dlbtn.main:hover{background:#ff8aac}',
250
253
  '.dlbtn:focus-visible{outline:2px solid #fff;outline-offset:2px}',
251
254
  '.dlhint{color:var(--muted);font-size:12px;}',
252
255
  /* 没配公网地址时的提示条:用户打不开得知道是「没配域名」,不是「插件坏了」 */
@@ -309,6 +312,8 @@ export function renderPlayerPage (info: PlayerPageInfo): string {
309
312
  const platform = escapeHtml(platformLabel(info.platform))
310
313
  const expire = formatExpire(info.expireAt)
311
314
  const danmakuCount = Number(info.danmakuCount) || 0
315
+ /** 有没有**单独的音轨**(B站这类音视频分离的):有就 <video muted> + <audio> 同时播 */
316
+ const hasAudio = typeof info.audioPath === 'string' && info.audioPath.length > 0
312
317
  const duration = formatDuration(info.durationSeconds)
313
318
  const script = PLAYER_SCRIPT.split('__KKK_TOKEN__').join(token)
314
319
 
@@ -365,7 +370,12 @@ export function renderPlayerPage (info: PlayerPageInfo): string {
365
370
  + ' </div>\n'
366
371
  + ' <div class="player">\n'
367
372
  + ' <div class="video-wrap" id="videoWrap">\n'
368
- + ' <video id="video" src="/kkk/player/' + token + '/video" preload="metadata" playsinline webkit-playsinline></video>\n'
373
+ /**
374
+ * 音视频分离时:视频**静音**播画面,声音交给下面这个 <audio> 一起播
375
+ * (用户要求:默认不在服务器合成,浏览器端同时播放即可)。
376
+ */
377
+ + ' <video id="video" src="/kkk/player/' + token + '/video" preload="metadata" playsinline webkit-playsinline' + (hasAudio ? ' muted' : '') + '></video>\n'
378
+ + (hasAudio ? ' <audio id="audioTrack" src="/kkk/player/' + token + '/audio" preload="auto"></audio>\n' : '')
369
379
  + ' <canvas id="danmaku"></canvas>\n'
370
380
  + ' <div id="notice" class="notice" hidden></div>\n'
371
381
  + ' <button type="button" class="bigplay" id="bigPlay" aria-label="播放">' + ICON.play + '</button>\n'
@@ -411,9 +421,23 @@ export function renderPlayerPage (info: PlayerPageInfo): string {
411
421
  * 放在播放器下面单独一条 —— 作品信息可能缺(那时不会渲染操作按钮排),下载入口不能跟着一起消失。
412
422
  */
413
423
  + ' <div class="downbar">'
414
- + '<a class="dlbtn" id="downloadBtn" href="/kkk/player/' + token + '/video?download=1" download>'
415
- + ICON.download + '<span>下载视频</span></a>'
416
- + '<span class="dlhint">保存到本机(原画质,不重新编码)</span></div>\n'
424
+ + (hasAudio
425
+ /**
426
+ * 音视频分离:给两个「分别下载」+ 一个「服务器合成后下载」。
427
+ * 分别是秒下(就是两个原文件),合成那条第一次点要等一两秒(服务器跑一次 ffmpeg -c copy)。
428
+ */
429
+ ? '<a class="dlbtn" href="/kkk/player/' + token + '/video?download=1" download>'
430
+ + ICON.download + '<span>下载画面</span></a>'
431
+ + '<a class="dlbtn" href="/kkk/player/' + token + '/audio?download=1" download>'
432
+ + ICON.download + '<span>下载声音</span></a>'
433
+ + '<a class="dlbtn main" href="/kkk/player/' + token + '/merged?download=1" download>'
434
+ + ICON.download + '<span>合成后下载</span></a>'
435
+ + '<span class="dlhint">画面与声音是分开的两条流:前两个直接下载,最后一个由服务器合成成一个视频(第一次要等一两秒);'
436
+ + '想在播放器里看就直接播放,声音会自动跟上</span>'
437
+ : '<a class="dlbtn" id="downloadBtn" href="/kkk/player/' + token + '/video?download=1" download>'
438
+ + ICON.download + '<span>下载视频</span></a>'
439
+ + '<span class="dlhint">保存到本机(原画质,不重新编码)</span>')
440
+ + '</div>\n'
417
441
  + linkRow + '\n'
418
442
  + (actions ? ' <div class="actions">' + actions + '</div>\n' : '')
419
443
  + (info.localOnly
@@ -610,6 +634,14 @@ const PLAYER_SCRIPT = [
610
634
  " var notice = document.getElementById('notice')",
611
635
  " var video = document.getElementById('video')",
612
636
  " var canvas = document.getElementById('danmaku')",
637
+ " /**",
638
+ " * 单独的音轨(B站这类音视频分离的)。有它的时候:",
639
+ " * - 视频元素是 muted 的(只出画面),声音由这个 <audio> 出;",
640
+ " * - 播放 / 暂停 / 拖动进度都要**带着它一起**,否则声音会跟画面错开。",
641
+ " */",
642
+ " var audioTrack = document.getElementById('audioTrack')",
643
+ " /** 音量控制作用在谁身上:有独立音轨就是音轨,否则是视频自己 */",
644
+ " var soundEl = audioTrack || video",
613
645
  " var toggle = document.getElementById('dmOn')",
614
646
  " var countEl = document.getElementById('dmCount')",
615
647
  " var settingsBtn = document.getElementById('dmSettingsBtn')",
@@ -794,6 +826,8 @@ const PLAYER_SCRIPT = [
794
826
  " cursor++",
795
827
  " guard++",
796
828
  " }",
829
+ " // 音画同步:偏差超过 0.25 秒就拉回来(长时间播放 / 卡顿后必须对一次)",
830
+ " if (audioTrack && !video.paused && !video.ended && !video.seeking) alignAudio()",
797
831
  " draw(dt, now)",
798
832
  " }",
799
833
  "",
@@ -998,12 +1032,34 @@ const PLAYER_SCRIPT = [
998
1032
  " })",
999
1033
  " }",
1000
1034
  "",
1035
+ " /** 把音轨拉到和画面同一个位置(拖动 / 卡顿时对表) */",
1036
+ " function alignAudio () {",
1037
+ " if (!audioTrack) return",
1038
+ " try {",
1039
+ " if (Math.abs((audioTrack.currentTime || 0) - video.currentTime) > 0.25) audioTrack.currentTime = video.currentTime",
1040
+ " } catch (error) { /* 还没加载好就等下一帧 */ }",
1041
+ " }",
1042
+ "",
1043
+ " function playAudio () {",
1044
+ " if (!audioTrack) return",
1045
+ " alignAudio()",
1046
+ " var started = audioTrack.play()",
1047
+ " if (started && started.catch) started.catch(function () { /* 画面已经在放,声音起不来就先算了 */ })",
1048
+ " }",
1049
+ "",
1050
+ " function pauseAudio () {",
1051
+ " if (!audioTrack) return",
1052
+ " try { audioTrack.pause() } catch (error) { /* 忽略 */ }",
1053
+ " }",
1054
+ "",
1001
1055
  " function togglePlay () {",
1002
1056
  " if (video.paused || video.ended) {",
1003
1057
  " var started = video.play()",
1004
1058
  " if (started && started.catch) started.catch(function () { showNotice('浏览器拦住了自动播放,点一下画面中间的播放按钮就行') })",
1059
+ " playAudio()",
1005
1060
  " } else {",
1006
1061
  " video.pause()",
1062
+ " pauseAudio()",
1007
1063
  " }",
1008
1064
  " }",
1009
1065
  "",
@@ -1015,22 +1071,34 @@ const PLAYER_SCRIPT = [
1015
1071
  " }",
1016
1072
  "",
1017
1073
  " function syncMute () {",
1018
- " if (muteBtn) muteBtn.classList.toggle('is-muted', video.muted || video.volume === 0)",
1074
+ " if (muteBtn) muteBtn.classList.toggle('is-muted', soundEl.muted || soundEl.volume === 0)",
1019
1075
  " }",
1020
1076
  "",
1021
- " video.addEventListener('play', syncPlayState)",
1022
- " video.addEventListener('pause', syncPlayState)",
1023
- " video.addEventListener('ended', syncPlayState)",
1077
+ " video.addEventListener('play', function () { syncPlayState(); playAudio() })",
1078
+ " video.addEventListener('pause', function () { syncPlayState(); pauseAudio() })",
1079
+ " video.addEventListener('ended', function () { syncPlayState(); pauseAudio() })",
1024
1080
  " video.addEventListener('timeupdate', drawProgress)",
1025
1081
  " video.addEventListener('progress', drawProgress)",
1026
1082
  " video.addEventListener('durationchange', drawProgress)",
1027
1083
  " video.addEventListener('loadedmetadata', drawProgress)",
1028
1084
  " video.addEventListener('volumechange', syncMute)",
1085
+ " // 拖动进度(含键盘 / 快进):画面跳完马上把音轨对齐,不然会差出好几秒",
1086
+ " video.addEventListener('seeked', alignAudio)",
1087
+ " if (audioTrack) {",
1088
+ " audioTrack.addEventListener('volumechange', syncMute)",
1089
+ " // 音轨自己卡住 / 被系统暂停时,跟着画面走",
1090
+ " audioTrack.addEventListener('pause', function () { if (!video.paused && !video.ended) playAudio() })",
1091
+ " audioTrack.addEventListener('error', function () { showNotice('声音加载失败,画面继续播放(可以在下面单独下载声音)') })",
1092
+ " }",
1029
1093
  " if (playBtn) playBtn.addEventListener('click', togglePlay)",
1030
1094
  " if (bigPlay) bigPlay.addEventListener('click', togglePlay)",
1031
1095
  " // 点画面中间也能播放 / 暂停(弹幕 canvas 是 pointer-events:none,点它等于点在 video 上)",
1032
1096
  " video.addEventListener('click', togglePlay)",
1033
- " if (muteBtn) muteBtn.addEventListener('click', function () { video.muted = !video.muted; syncMute() })",
1097
+ " if (muteBtn) {",
1098
+ " // 有独立音轨时,静音 / 取消静音作用在音轨上(视频一直是 muted 的)",
1099
+ " if (audioTrack) soundEl.muted = true",
1100
+ " muteBtn.addEventListener('click', function () { soundEl.muted = !soundEl.muted; syncMute() })",
1101
+ " }",
1034
1102
  "",
1035
1103
  " function isFullscreen () { return !!(document.fullscreenElement || document.webkitFullscreenElement) }",
1036
1104
  "",
@@ -4,7 +4,9 @@
4
4
  * 三条路由(都挂在自己拼的 `/kkk/player` 前缀下,和配置面板的 /kkk 互不干扰):
5
5
  * GET /kkk/player/:token 播放页(HTML)
6
6
  * GET /kkk/player/:token/video 视频本体,支持 Range(拖进度条靠它);`?download=1` = 下载
7
- * GET /kkk/player/:token/download 同上,等价于 `/video?download=1`(带 Content-Disposition)
7
+ * GET /kkk/player/:token/audio 单独的音轨(B站这种音视频分离的);`?download=1` = 下载音频
8
+ * GET /kkk/player/:token/merged **按需**用 ffmpeg 合成音视频后下载/播放(第一次会慢一两秒)
9
+ * GET /kkk/player/:token/download 等价于 `/video?download=1`(带 Content-Disposition)
8
10
  * GET /kkk/player/:token/danmaku 弹幕 JSON
9
11
  *
10
12
  * 两种落地方式:
@@ -17,6 +19,7 @@
17
19
  */
18
20
  import fs from 'node:fs'
19
21
  import http from 'node:http'
22
+ import path from 'node:path'
20
23
 
21
24
  import { logger } from 'node-karin'
22
25
 
@@ -24,8 +27,11 @@ import { renderExpiredPage, renderPlayerPage } from './page'
24
27
  import {
25
28
  getPlayerSession,
26
29
  isValidPlayerToken,
30
+ markPlayerMerged,
27
31
  readPlayerDanmaku,
32
+ resolvePlayerAudio,
28
33
  resolvePlayerCover,
34
+ resolvePlayerMerged,
29
35
  resolvePlayerVideo
30
36
  } from './store'
31
37
 
@@ -118,13 +124,13 @@ export function sanitizeDownloadName (title: unknown): string {
118
124
  * 两个文件名都给:`filename=` 用 ASCII 兜底名(老客户端 / 头编码限制),
119
125
  * `filename*=` 用 RFC 5987 的 UTF-8 形式放原名(现代浏览器优先用它,中文名不会变成下划线)。
120
126
  */
121
- export function downloadDisposition (title: unknown): string {
122
- const full = sanitizeDownloadName(title) + '.mp4'
127
+ export function downloadDisposition (title: unknown, ext = '.mp4'): string {
128
+ const full = sanitizeDownloadName(title) + ext
123
129
  // 纯中文标题会变成一排下划线(对老客户端毫无意义),这种情况直接给个通用名
124
130
  const asciiRaw = full.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_')
125
131
  // 只在**扩展名之前**数有效字符:纯中文标题会变成「_____.mp4」,那 3 个字母来自 .mp4 不能算数
126
- const asciiBase = asciiRaw.replace(/\.(mp4|mkv|webm|mov)$/i, '')
127
- const ascii = /[A-Za-z0-9]/.test(asciiBase) ? asciiRaw : 'video.mp4'
132
+ const asciiBase = asciiRaw.replace(/\.(mp4|mkv|webm|mov|m4a|aac|mp3)$/i, '')
133
+ const ascii = /[A-Za-z0-9]/.test(asciiBase) ? asciiRaw : 'video' + ext
128
134
  const encoded = encodeURIComponent(full).replace(/['()*]/g, (char) => '%' + char.charCodeAt(0).toString(16).toUpperCase())
129
135
  return 'attachment; filename="' + ascii + '"; filename*=UTF-8\'\'' + encoded
130
136
  }
@@ -133,27 +139,41 @@ export function downloadDisposition (title: unknown): string {
133
139
  * 视频响应:支持 Range,未过期时是 video/mp4。
134
140
  * @param download 为 true 时带上 `Content-Disposition: attachment`(浏览器直接下载),Range 照旧支持
135
141
  */
136
- function videoResponse (token: string, range?: string, head = false, download = false): PlayerHttpResponse {
137
- const video = resolvePlayerVideo(token)
138
- if (!video) return notFound(false)
142
+ /**
143
+ * 通用文件响应:支持 Range。
144
+ *
145
+ * @param file 文件路径与大小
146
+ * @param contentType 响应的 Content-Type(视频 / 音轨 / 合并后的视频各不同)
147
+ * @param ext 下载时的扩展名(决定 Content-Disposition 里的文件名)
148
+ * @param download 是否带 \`Content-Disposition: attachment\`
149
+ */
150
+ function fileResponse (
151
+ token: string,
152
+ file: { path: string, size: number },
153
+ contentType: string,
154
+ range?: string,
155
+ head = false,
156
+ download = false,
157
+ ext = '.mp4'
158
+ ): PlayerHttpResponse {
139
159
  const base: Record<string, string> = {
140
- 'Content-Type': 'video/mp4',
160
+ 'Content-Type': contentType,
141
161
  'Accept-Ranges': 'bytes',
142
162
  'Cache-Control': 'no-store'
143
163
  }
144
164
  // 下载:文件名取会话标题(清洗过),Range 行为不变(浏览器正常下载时也不会带 Range)
145
- if (download) base['Content-Disposition'] = downloadDisposition(getPlayerSession(token)?.title)
146
- const parsed = parseRange(range, video.size)
165
+ if (download) base['Content-Disposition'] = downloadDisposition(getPlayerSession(token)?.title, ext)
166
+ const parsed = parseRange(range, file.size)
147
167
  if (parsed === 'invalid') {
148
168
  return {
149
169
  status: 416,
150
- headers: { ...base, 'Content-Range': 'bytes */' + video.size },
170
+ headers: { ...base, 'Content-Range': 'bytes */' + file.size },
151
171
  body: Buffer.from('请求的范围无效')
152
172
  }
153
173
  }
154
174
  if (!parsed) {
155
- base['Content-Length'] = String(video.size)
156
- return { status: 200, headers: base, file: head ? undefined : { path: video.path, start: 0, end: video.size - 1 } }
175
+ base['Content-Length'] = String(file.size)
176
+ return { status: 200, headers: base, file: head ? undefined : { path: file.path, start: 0, end: file.size - 1 } }
157
177
  }
158
178
  const length = parsed.end - parsed.start + 1
159
179
  return {
@@ -161,9 +181,70 @@ function videoResponse (token: string, range?: string, head = false, download =
161
181
  headers: {
162
182
  ...base,
163
183
  'Content-Length': String(length),
164
- 'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + video.size
184
+ 'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + file.size
165
185
  },
166
- file: head ? undefined : { path: video.path, start: parsed.start, end: parsed.end }
186
+ file: head ? undefined : { path: file.path, start: parsed.start, end: parsed.end }
187
+ }
188
+ }
189
+
190
+ /** 视频本体(分离音轨时这里只有画面) */
191
+ function videoResponse (token: string, range?: string, head = false, download = false): PlayerHttpResponse {
192
+ const video = resolvePlayerVideo(token)
193
+ if (!video) return notFound(false)
194
+ return fileResponse(token, video, 'video/mp4', range, head, download, '.mp4')
195
+ }
196
+
197
+ /** 单独的音轨(B站这类音视频分离的):\`<audio>\` 直接播它,\`?download=1\` 下载 m4a */
198
+ function audioResponse (token: string, range?: string, head = false, download = false): PlayerHttpResponse {
199
+ const audio = resolvePlayerAudio(token)
200
+ if (!audio) return notFound(false)
201
+ return fileResponse(token, audio, 'audio/mp4', range, head, download, '.m4a')
202
+ }
203
+
204
+ /**
205
+ * **按需合成**:把画面和音轨用 ffmpeg 合成一条 mp4(\`-c copy\`,不重新编码,通常一两秒)。
206
+ *
207
+ * 为什么要按需:用户要求「默认不合并音频、浏览器里同时播放就行」——
208
+ * 解析时不再花时间合成,只有点了「服务器合并后下载」才做,而且做完会留在会话目录里,
209
+ * 同一条链接再点就是秒回(到期随会话一起删)。
210
+ *
211
+ * @returns 合成好的文件;没有独立音轨 / ffmpeg 失败时返回 null(路由回 404)
212
+ */
213
+ async function ensureMerged (token: string): Promise<{ path: string, size: number } | null> {
214
+ const cached = resolvePlayerMerged(token)
215
+ if (cached) return cached
216
+ const video = resolvePlayerVideo(token)
217
+ const audio = resolvePlayerAudio(token)
218
+ if (!video || !audio) return null
219
+ const session = getPlayerSession(token)
220
+ if (!session) return null
221
+ const target = path.join(session.dir, 'merged.mp4')
222
+ const started = Date.now()
223
+ try {
224
+ const { spawn } = await import('node:child_process')
225
+ const { resolveFfmpegBin } = await import('node-karin')
226
+ const bin = resolveFfmpegBin()
227
+ await new Promise<void>((resolve, reject) => {
228
+ const proc = spawn(bin, [
229
+ '-y',
230
+ '-i', video.path,
231
+ '-i', audio.path,
232
+ '-c', 'copy',
233
+ '-movflags', '+faststart',
234
+ target
235
+ ], { stdio: 'ignore' })
236
+ proc.on('error', reject)
237
+ proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error('ffmpeg exit ' + code))))
238
+ })
239
+ const stat = fs.statSync(target)
240
+ markPlayerMerged(token, 'merged.mp4')
241
+ logger.mark('[在线播放] 已按需合成音视频(' + (stat.size / 1024 / 1024).toFixed(1) + 'MB,'
242
+ + (Date.now() - started) + 'ms):' + token)
243
+ return { path: target, size: stat.size }
244
+ } catch (error: any) {
245
+ logger.warn('[在线播放] 合成音视频失败(' + (Date.now() - started) + 'ms): ' + String(error?.message ?? error))
246
+ try { fs.rmSync(target, { force: true }) } catch { /* 忽略 */ }
247
+ return null
167
248
  }
168
249
  }
169
250
 
@@ -228,6 +309,17 @@ export async function handlePlayerRequest (request: PlayerHttpRequest): Promise<
228
309
  }
229
310
  }
230
311
  if (action === 'video') return videoResponse(token, request.range, method === 'HEAD', wantDownload)
312
+ // 独立音轨(B站音视频分离时才有):播放页的 <audio> 播它,?download=1 下载 m4a
313
+ if (action === 'audio') return audioResponse(token, request.range, method === 'HEAD', wantDownload)
314
+ /**
315
+ * 按需合成后下载 / 播放:第一次会跑一次 ffmpeg -c copy(一两秒),之后走缓存。
316
+ * 合成失败(没有独立音轨 / 没有 ffmpeg)回 404,页面上的按钮也就不该点得动。
317
+ */
318
+ if (action === 'merged') {
319
+ const merged = await ensureMerged(token)
320
+ if (!merged) return notFound(false)
321
+ return fileResponse(token, merged, 'video/mp4', request.range, method === 'HEAD', wantDownload, '.mp4')
322
+ }
231
323
  // 独立路由:`/kkk/player/<token>/download` 与 `/video?download=1` 完全等价
232
324
  if (action === 'download') return videoResponse(token, request.range, method === 'HEAD', true)
233
325
  if (action === 'danmaku') return danmakuResponse(token)
@@ -50,6 +50,20 @@ export interface PlayerSession {
50
50
  filePath: string
51
51
  /** 视频大小(字节) */
52
52
  sizeBytes: number
53
+ /**
54
+ * **单独的音轨文件**(可选)。
55
+ *
56
+ * 用户要求「默认不要服务器合并音频,浏览器里同时播放就行」:B站是分离的音视频流,
57
+ * 以前要先用 ffmpeg 合成一条 mp4(慢、吃 CPU),现在**默认不合成** ——
58
+ * 视频流和音频流各存一份,播放页用 `<video muted>` + `<audio>` 同时播放。
59
+ * 只有用户点「服务器合并后下载」时才按需合成(见 server.ts 的 merged 路由)。
60
+ * 抖音那种本身就带音轨的视频没有这个字段。
61
+ */
62
+ audioPath?: string
63
+ /** 音轨大小(字节) */
64
+ audioSizeBytes?: number
65
+ /** 已经按需合成好的文件(会话目录里的文件名,例如 merged.mp4),第一次合并后记下来 */
66
+ merged?: string
53
67
  /** 弹幕条数 */
54
68
  danmakuCount: number
55
69
  /** 创建时间 */
@@ -180,6 +194,9 @@ function normalizeSession (raw: any): PlayerSession | null {
180
194
  platform: String(raw.platform ?? ''),
181
195
  dir: typeof raw.dir === 'string' && raw.dir ? raw.dir : path.dirname(filePath),
182
196
  filePath,
197
+ audioPath: typeof raw.audioPath === 'string' && raw.audioPath ? raw.audioPath : undefined,
198
+ audioSizeBytes: Number.isFinite(Number(raw.audioSizeBytes)) ? Number(raw.audioSizeBytes) : undefined,
199
+ merged: typeof raw.merged === 'string' && raw.merged ? raw.merged : undefined,
183
200
  sizeBytes: Number(raw.sizeBytes) || 0,
184
201
  danmakuCount: Number(raw.danmakuCount) || 0,
185
202
  createdAt: Number(raw.createdAt) || Date.now(),
@@ -263,6 +280,11 @@ function createToken (): string {
263
280
  */
264
281
  export function registerPlayerSession (input: {
265
282
  videoPath: string
283
+ /**
284
+ * 单独的音轨文件(可选,见 {@link PlayerSession.audioPath})。
285
+ * 给了就一起搬进会话目录,播放页同时播放;没给就是「视频自带音轨」。
286
+ */
287
+ audioPath?: string
266
288
  title?: string
267
289
  platform?: string
268
290
  danmaku?: PlayerDanmakuItem[]
@@ -297,6 +319,26 @@ export function registerPlayerSession (input: {
297
319
  fs.rmSync(source, { force: true })
298
320
  }
299
321
 
322
+ /**
323
+ * 单独的音轨:也**搬**进会话目录(和视频同一套逻辑:同分区改名、跨分区复制 + 删源)。
324
+ * 搬不动就当作「没有独立音轨」,不影响视频本身能播。
325
+ */
326
+ let audioTarget: string | undefined
327
+ if (input.audioPath && fs.existsSync(input.audioPath)) {
328
+ const candidate = path.join(dir, 'audio.m4a')
329
+ try {
330
+ try {
331
+ fs.renameSync(input.audioPath, candidate)
332
+ } catch {
333
+ fs.copyFileSync(input.audioPath, candidate)
334
+ fs.rmSync(input.audioPath, { force: true })
335
+ }
336
+ audioTarget = candidate
337
+ } catch (error: any) {
338
+ logger.warn('[在线播放] 音轨文件搬进会话目录失败(将只播视频流): ' + String(error?.message ?? error))
339
+ }
340
+ }
341
+
300
342
  const danmaku = Array.isArray(input.danmaku) ? input.danmaku : []
301
343
  fs.writeFileSync(path.join(dir, 'danmaku.json'), JSON.stringify({ total: danmaku.length, items: danmaku }))
302
344
 
@@ -322,6 +364,8 @@ export function registerPlayerSession (input: {
322
364
  dir,
323
365
  filePath: target,
324
366
  sizeBytes: Number(fs.statSync(target).size) || 0,
367
+ audioPath: audioTarget,
368
+ audioSizeBytes: audioTarget ? (Number(fs.statSync(audioTarget).size) || 0) : undefined,
325
369
  danmakuCount: danmaku.length,
326
370
  createdAt: now,
327
371
  expireAt: now + normalizeExpireMinutes(input.expireMinutes) * 60 * 1000,
@@ -413,6 +457,46 @@ export function resolvePlayerVideo (token: unknown): { path: string, size: numbe
413
457
  }
414
458
  }
415
459
 
460
+ /**
461
+ * 取单独的音轨文件(没有独立音轨就返回 null,路由回 404)。
462
+ *
463
+ * 独立音轨 = 解析时没让服务器合成,播放页用 `<audio>` 和视频一起播(见 PlayerSession.audioPath)。
464
+ */
465
+ export function resolvePlayerAudio (token: unknown): { path: string, size: number } | null {
466
+ const session = getPlayerSession(token)
467
+ const audioPath = session?.audioPath
468
+ if (!audioPath) return null
469
+ try {
470
+ const stat = fs.statSync(audioPath)
471
+ if (!stat.isFile()) return null
472
+ return { path: audioPath, size: stat.size }
473
+ } catch {
474
+ return null
475
+ }
476
+ }
477
+
478
+ /** 记住「按需合成的文件」文件名(它会留在会话目录里,到期一起清掉) */
479
+ export function markPlayerMerged (token: string, fileName: string): void {
480
+ const session = sessions.get(String(token))
481
+ if (!session) return
482
+ session.merged = fileName
483
+ persistIndex()
484
+ }
485
+
486
+ /** 已经合成好的合并文件路径(没有就 null) */
487
+ export function resolvePlayerMerged (token: unknown): { path: string, size: number } | null {
488
+ const session = getPlayerSession(token)
489
+ if (!session?.merged) return null
490
+ try {
491
+ const file = path.join(session.dir, session.merged)
492
+ const stat = fs.statSync(file)
493
+ if (!stat.isFile()) return null
494
+ return { path: file, size: stat.size }
495
+ } catch {
496
+ return null
497
+ }
498
+ }
499
+
416
500
  /** 删掉一个会话的文件:先按 Common.removeFile 删视频,再把整个会话目录清掉 */
417
501
  async function removePlayerFiles (session: PlayerSession): Promise<void> {
418
502
  await removeFileQuietly(session.filePath)