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 +8 -0
- package/lib/karin/platform/bilibili/bilibili.js +51 -14
- package/lib/player/index.js +6 -1
- package/lib/player/page.js +77 -9
- package/lib/player/server.js +138 -17
- package/lib/player/store.js +73 -0
- package/package.json +1 -1
- package/scripts/smoke-player.cjs +15 -5
- package/src/karin/platform/bilibili/bilibili.ts +49 -14
- package/src/player/index.ts +11 -1
- package/src/player/page.ts +77 -9
- package/src/player/server.ts +108 -16
- package/src/player/store.ts +84 -0
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 播放接口的
|
|
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
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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,
|
package/lib/player/index.js
CHANGED
|
@@ -526,7 +526,11 @@ async function publishOnlinePlayer(e, input) {
|
|
|
526
526
|
* 体积上限:超过就不做在线播放,回一句说明并返回 false ——
|
|
527
527
|
* 调用方拿到 false 会退回「直接发送视频文件」,用户不会什么都没有。
|
|
528
528
|
*/
|
|
529
|
-
|
|
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,
|
package/lib/player/page.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
+
|
|
388
|
-
|
|
389
|
-
|
|
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',
|
|
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)
|
|
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
|
"",
|
package/lib/player/server.js
CHANGED
|
@@ -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
|
|
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) +
|
|
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
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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':
|
|
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,
|
|
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 */' +
|
|
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(
|
|
140
|
-
return { status: 200, headers: base, file: head ? undefined : { path:
|
|
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 + '/' +
|
|
189
|
+
'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + file.size
|
|
149
190
|
},
|
|
150
|
-
file: head ? undefined : { path:
|
|
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);
|
package/lib/player/store.js
CHANGED
|
@@ -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
package/scripts/smoke-player.cjs
CHANGED
|
@@ -397,7 +397,12 @@ setTimeout(async () => {
|
|
|
397
397
|
if (matched) {
|
|
398
398
|
fs.writeFileSync(scriptFile, matched[1])
|
|
399
399
|
try {
|
|
400
|
-
|
|
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
|
-
|
|
902
|
-
|
|
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(
|
|
905
|
-
!!bundleText && bundleText.split('disabled:' + playerOffMarker).length - 1 ===
|
|
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 播放接口的
|
|
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
|
-
|
|
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
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
if (
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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,
|
package/src/player/index.ts
CHANGED
|
@@ -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
|
-
|
|
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,
|
package/src/player/page.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
+
|
|
415
|
-
|
|
416
|
-
|
|
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',
|
|
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)
|
|
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
|
"",
|
package/src/player/server.ts
CHANGED
|
@@ -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
|
|
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) +
|
|
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
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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':
|
|
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,
|
|
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 */' +
|
|
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(
|
|
156
|
-
return { status: 200, headers: base, file: head ? undefined : { path:
|
|
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 + '/' +
|
|
184
|
+
'Content-Range': 'bytes ' + parsed.start + '-' + parsed.end + '/' + file.size
|
|
165
185
|
},
|
|
166
|
-
file: head ? undefined : { path:
|
|
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)
|
package/src/player/store.ts
CHANGED
|
@@ -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)
|