Compare commits
5
Commits
470b7668ac
...
9c94a72c4d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c94a72c4d | ||
|
|
e35a4f7cef | ||
|
|
eb250bb842 | ||
|
|
7a396a8852 | ||
|
|
0dfa224bac |
@@ -81,6 +81,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
|
"mergeDelDanmu": false, // 合并完成后删除弹幕文件(ass、nick.ass、jsonl)
|
||||||
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
"mergeOutputDir": "", // 合并完成后成片移动到该文件夹,留空则不移动
|
||||||
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
"mergeBitrate": 0, // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||||
|
"danmuFontSize": 0, // 合并时弹幕文字的字号(像素),0 表示跟随弹幕文件;头像随字号等比缩放
|
||||||
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源
|
"defaultBitrate": 0, // 新添加主播的默认 bitrate,0 表示码率最高的直播源
|
||||||
|
|
||||||
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
|
||||||
@@ -114,6 +115,8 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
|
|||||||
|
|
||||||
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
|
||||||
|
|
||||||
|
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
|
||||||
|
|
||||||
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
`recordFormat`决定录播原片的封装格式,默认为`mp4`(和原版`config.Output`的默认值一致),也可以改成`flv`、`ts`、`mkv`,在 web UI 的`录制`分区用下拉框修改。录制始终是`-c copy`直接封装直播源、不重新编码,所以四种格式的**画质和码率完全一样,区别只在容器**。
|
||||||
|
|
||||||
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
|
正常停止(web UI 的停止按钮、`stoprecord`、`Ctrl+C`、`quit`)会先向 ffmpeg 发`q`让它正常收尾,**四种格式都能正常播放**——本机 ffmpeg 6.0 实测录 5 秒后发`q`:mp4 161 KB、flv 163 KB、ts 191 KB、mkv 160 KB,全部可读且时长 5.14 秒。但如果进程被强杀(任务管理器结束进程、断电、系统崩溃),差别就很大了——同样录制 8 秒后强杀:
|
||||||
@@ -156,7 +159,13 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
|
|||||||
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
| 文件 | 磁盘剩余容量、录播和成片的文件列表 |
|
||||||
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
| 设置 | 是否允许外网访问、管理口令的查看与更换 |
|
||||||
|
|
||||||
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
开关和下拉框改完立即保存;文本框(目录、地址、口令这些)填完按回车或点右上角的`保存设置`才生效,这一条在每个有文本框的分区里也写了提示。
|
||||||
|
|
||||||
|
目录和文件两条路都行:直接在框里填,或者点框右边的`浏览`逐级点选。弹窗里可以一路点进子文件夹、点`C:`/`D:`这类按钮(Linux 上是`/`、`/home`、`/mnt` 这些常用位置)换根位置,地址栏本身也能粘贴路径回车跳转,随时在点选和手填之间切换;选好点`选用这个文件夹`填回设置框,仍然是按回车或点保存才生效。填的是文件时(比如 ffmpeg 路径)列出的是文件,双击即选中并填入。默认不显示以点开头的隐藏项,需要时勾上`显示隐藏项`。
|
||||||
|
|
||||||
|
要选的是一组文件时(`手动合并`里的视频框,一行一个)用的是同一套弹窗,区别只是能多选:点一下文件名即勾选、再点一下取消(手机上也一样点,不用按 Ctrl),可以换到别的文件夹继续勾,底部显示`已选 N 项`、点`清空`能重来,双击某个文件则是「把这一个一起追加进去并关窗」。选完点`选用这 N 个文件`是**追加**到框里,手填的内容和其他已经选好的行都不会被清掉,重复的不会加两遍。填入时**录播目录下的文件只写文件名**(跟着保存目录走,以后改了保存目录也还认得),别的文件夹里的文件写绝对路径。这个视频框和旁边的弹幕框都只服务于一次手动合并,不属于设置项,所以选完不会有`有未保存的修改`提示,也不用去点`保存设置`,直接点`开始合并`即可。
|
||||||
|
|
||||||
|
页面每 3 秒从服务端取一次状态回填表单,但**不会覆盖你改过还没提交的字段**,所以填好的数字不会过几秒又被刷回旧值;有没提交的内容时顶栏会显示`有未保存的修改`(保存按钮右上角也有个红点),此时离开或刷新页面浏览器会再问一次。保存失败(比如成片目录填了个不存在的路径)时提示里会带上原因,并且**输入框里填的内容会保留**,改对了再存即可;开关和下拉框的立即保存不会带上前述还没提交的文本框内容,所以某个框填错也不会连累开关保存失败。
|
||||||
|
|
||||||
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
|
顶栏右上角一直显示当前状态(`录制中 N` / `直播中 N` / `空闲`)。窄屏上按钮收起成短文案、标题居中,手机上也能正常用;页面跟随系统的深浅色设置,深色模式下日志和表单自动变深色,不用额外配置。
|
||||||
|
|
||||||
@@ -191,7 +200,7 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
|
`-danmuburn`的弹幕文件支持`.jsonl`和`.ass`两种。给的是`.ass`时,如果同目录下已经有同名的非空`.jsonl`就直接用,否则按 ASS 里的`PlayResX`/`PlayResY`、字号和`\move`弹道把弹幕还原成 jsonl,并把还原结果写回弹幕文件旁边,下次再合就不用重新解析。`-out`不指定时输出到弹幕文件同目录,重名会自动加`(2)`之类的后缀,不会覆盖已有文件。ffmpeg 的路径除了`-ffmpeg`参数,也会读设置文件里的`ffmpegPath`;填相对路径时按程序所在目录解析,也可以填它所在的文件夹。
|
||||||
|
|
||||||
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
|
头像会按 uid 缓存到录制文件夹下的`avatar_cache`里,圆形裁切后的直径与弹幕文字的墨迹高度一致。头像图片支持 jpg、png、gif 和静态 webp:gif(包括多帧动图)取第一帧来裁切,动画 webp 解不开、遇到就只烧文字。上游原版的`.ass`里没有头像链接(`nick.ass`只有昵称和 uid),遇到这种情况会按 uid 向 AcFun 的`userInfo`接口反查头像,查不到的只烧文字。反查走网络且有超时,所以离线环境下合并会退化成纯文字弹幕并给出警告。
|
||||||
|
|
||||||
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
|
有 Intel 核显时优先用 QSV 硬件编码(`h264_qsv`),不可用或运行失败时自动回退到`libx264`。
|
||||||
|
|
||||||
@@ -205,6 +214,8 @@ acfunlive -danmuburn xxx.jsonl -video xxx.flv -ffmpeg D:\tools\ffmpeg.exe
|
|||||||
|
|
||||||
手动停止录制(web UI 的`停止`按钮或命令行里停止)不会触发自动合并,重新开始录制后新的一组文件仍会在直播结束时自动合并。这一场想补出成片的话,用下面的`手动合并`。合并需要 ffmpeg,且会占用 CPU 或显卡资源,长录播的合并耗时较长。
|
手动停止录制(web UI 的`停止`按钮或命令行里停止)不会触发自动合并,重新开始录制后新的一组文件仍会在直播结束时自动合并。这一场想补出成片的话,用下面的`手动合并`。合并需要 ffmpeg,且会占用 CPU 或显卡资源,长录播的合并耗时较长。
|
||||||
|
|
||||||
|
弹幕字号默认沿用弹幕文件里记的字号(1080p 下通常是 48~60),嫌大可以调`弹幕字号`,头像会跟着等比缩小。合并前会按最终字号把弹幕重新排一遍弹道:从上游原版的`.ass`还原出来的弹幕,原本是按「只有内容」的宽度占位的,而渲染用的是「昵称(uid):内容 + 头像」的宽度,占位更久,照搬原弹道会追尾叠字——重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
||||||
|
|
||||||
停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。
|
停止录播时会向 ffmpeg 发送正常退出的指令,让它把文件正常收尾(直接杀进程会在文件尾部留下损坏的数据包,mp4 甚至会因为缺少 moov atom 而整个读不出来)。
|
||||||
|
|
||||||
`autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。
|
`autoMerge`、`mergeDelVideo`、`mergeDelDanmu`三项互不影响,可以同时开启:第一项决定是否自动合并,后两项是合并成功后的清理动作。
|
||||||
|
|||||||
+12
-4
@@ -252,19 +252,22 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 成片直接输出到成片文件夹,不再事后再移动一次
|
// 成片先在本地合成,再落到成片文件夹:成片目录可能是网络盘或外接盘,
|
||||||
|
// 直接往里写、写到一半失败会在那里留下一个看起来正常的半成品
|
||||||
mergeDir := mergeSaveDir()
|
mergeDir := mergeSaveDir()
|
||||||
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
if err := os.MkdirAll(mergeDir, 0755); err != nil {
|
||||||
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
|
lPrintWarnf("创建成片文件夹 %s 失败:%v", mergeDir, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
|
outFile := filepath.Join(mergeDir, filepath.Base(strings.TrimSuffix(jsonlFile, ".jsonl")+"_弹幕合并.mp4"))
|
||||||
|
tmpOut := tempMergeOutput(jsonlFile, filepath.Base(outFile))
|
||||||
|
|
||||||
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
// 标记成正在写入,避免在 web UI 的文件管理里把还没写完的成片删掉
|
||||||
markFileActive(outFile, true)
|
markFileActive(tmpOut, true)
|
||||||
burnErr := burnDanmu(jsonl, video, outFile, offset)
|
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
||||||
markFileActive(outFile, false)
|
markFileActive(tmpOut, false)
|
||||||
if burnErr != nil {
|
if burnErr != nil {
|
||||||
|
_ = os.Remove(tmpOut)
|
||||||
if burnErr == errNoDanmu {
|
if burnErr == errNoDanmu {
|
||||||
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
|
lPrintWarn("弹幕文件里没有弹幕,跳过自动合并")
|
||||||
return
|
return
|
||||||
@@ -272,6 +275,11 @@ func autoMerge(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int
|
|||||||
lPrintErrf("自动合并失败:%v", burnErr)
|
lPrintErrf("自动合并失败:%v", burnErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if err := placeOutput(tmpOut, outFile); err != nil {
|
||||||
|
_ = os.Remove(tmpOut)
|
||||||
|
lPrintErrf("自动合并失败:%v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
lPrintln("自动合并完成:" + outFile)
|
lPrintln("自动合并完成:" + outFile)
|
||||||
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", outFile,
|
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", outFile,
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/gif"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 2x2 的静态 webp(纯红),用来确认 webp 解码器确实注册上了
|
||||||
|
const tinyWebPBase64 = "UklGRjwAAABXRUJQVlA4IDAAAADQAQCdASoCAAIAAgA0JaACdLoB+AADsAD+8Oj3/yC5YXXI1/8gP+QH/ID/+PIAAAA="
|
||||||
|
|
||||||
|
// 多帧 gif 解出来应该是第一帧(AcFun 上有动图头像,压成静态就用第一帧)
|
||||||
|
func TestDecodeAvatarGifTakesFirstFrame(t *testing.T) {
|
||||||
|
pal := color.Palette{color.RGBA{R: 255, A: 255}, color.RGBA{B: 255, A: 255}}
|
||||||
|
first := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
|
||||||
|
second := image.NewPaletted(image.Rect(0, 0, 8, 8), pal)
|
||||||
|
for i := range second.Pix {
|
||||||
|
second.Pix[i] = 1
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := gif.EncodeAll(&buf, &gif.GIF{
|
||||||
|
Image: []*image.Paletted{first, second},
|
||||||
|
Delay: []int{10, 10},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
src, err := decodeAvatar(buf.Bytes(), "image/gif")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gif 解码失败:%v", err)
|
||||||
|
}
|
||||||
|
r, g, b, _ := src.At(4, 4).RGBA()
|
||||||
|
if r>>8 != 255 || g>>8 != 0 || b>>8 != 0 {
|
||||||
|
t.Errorf("应该取第一帧(红),实际拿到 r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// jpg / png / 静态 webp 都能解,解不出来的要给中文原因
|
||||||
|
func TestDecodeAvatarFormats(t *testing.T) {
|
||||||
|
var pngBuf, jpgBuf bytes.Buffer
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 8, 8))
|
||||||
|
if err := png.Encode(&pngBuf, img); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := jpeg.Encode(&jpgBuf, img, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
webpData, err := base64.StdEncoding.DecodeString(tinyWebPBase64)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
data []byte
|
||||||
|
contentType string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"png", pngBuf.Bytes(), "image/png", false},
|
||||||
|
{"jpeg", jpgBuf.Bytes(), "image/jpeg", false},
|
||||||
|
{"webp", webpData, "image/webp", false},
|
||||||
|
{"不是图片", []byte("这不是图片"), "text/html", true},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
src, err := decodeAvatar(c.data, c.contentType)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("应该报错")
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(err.Error()), []byte(c.contentType)) {
|
||||||
|
t.Errorf("错误信息里应该带上 Content-Type 方便排查:%v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("解码失败:%v", err)
|
||||||
|
}
|
||||||
|
if src == nil {
|
||||||
|
t.Fatal("解出来是空的")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解出来的头像要能走通圆形裁切 + 缓存用的 PNG 编码(gif 是 *image.Paletted,和 jpg/png 的底层类型都不同)
|
||||||
|
func TestDecodeAvatarGifGoesThroughCircleAvatar(t *testing.T) {
|
||||||
|
pal := color.Palette{color.RGBA{G: 255, A: 255}, color.RGBA{R: 255, B: 255, A: 255}}
|
||||||
|
frame := image.NewPaletted(image.Rect(0, 0, 16, 16), pal)
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := gif.EncodeAll(&buf, &gif.GIF{Image: []*image.Paletted{frame}, Delay: []int{10}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src, err := decodeAvatar(buf.Bytes(), "image/gif")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := png.Encode(&out, circleAvatar(src, avatarCacheSize)); err != nil {
|
||||||
|
t.Fatalf("圆形裁切后的 PNG 编码失败:%v", err)
|
||||||
|
}
|
||||||
|
if out.Len() == 0 {
|
||||||
|
t.Fatal("编码出来是空的")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
// web UI 的路径选择器:给设置里的目录 / 文件框提供「浏览」
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 选择器里的一个条目
|
||||||
|
type browseEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Path string `json:"path"` // 完整路径,直接回填到设置里
|
||||||
|
Dir bool `json:"dir"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开目录的结果
|
||||||
|
type browseResp struct {
|
||||||
|
Path string `json:"path"` // 当前展开的目录,空表示只给根列表
|
||||||
|
Parent string `json:"parent"` // 上级目录,已经到根时为空
|
||||||
|
Roots []browseEntry `json:"roots"` // 盘符 / 常用挂载点
|
||||||
|
Entries []browseEntry `json:"entries"` // 子文件夹(选文件时还含文件)
|
||||||
|
Truncated bool `json:"truncated"` // 条目太多只返回了一部分
|
||||||
|
Error string `json:"error"` // 展开失败的原因
|
||||||
|
}
|
||||||
|
|
||||||
|
// 单个目录最多返回多少项,避免几万个文件的目录把页面卡住
|
||||||
|
const browseMaxEntries = 2000
|
||||||
|
|
||||||
|
// 以点开头的当成隐藏项(Linux 下的 .config,Windows 下各类工具留下的 .cache 之类)
|
||||||
|
func browseHidden(name string) bool {
|
||||||
|
return strings.HasPrefix(name, ".")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上级目录,已经是根(盘符或 /)时返回空
|
||||||
|
func browseParent(abs string) string {
|
||||||
|
parent := filepath.Dir(abs)
|
||||||
|
if parent == abs {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return parent
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列出 path 下的内容,wantFiles 为真时连文件一起列(选文件用)
|
||||||
|
// truncated 表示条目太多被截断了
|
||||||
|
func browseList(path string, wantFiles, showHidden bool) (entries []browseEntry, parent string, truncated bool, err error) {
|
||||||
|
abs, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", false, err
|
||||||
|
}
|
||||||
|
parent = browseParent(abs)
|
||||||
|
items, err := os.ReadDir(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, parent, false, err
|
||||||
|
}
|
||||||
|
out := make([]browseEntry, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
name := item.Name()
|
||||||
|
if !showHidden && browseHidden(name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
full := filepath.Join(abs, name)
|
||||||
|
isDir := item.IsDir()
|
||||||
|
// ReadDir 用的是 lstat,指向文件夹的符号链接会被当成文件,这里补一次判断
|
||||||
|
if !isDir && item.Type()&fs.ModeSymlink != 0 {
|
||||||
|
if info, err := os.Stat(full); err == nil {
|
||||||
|
isDir = info.IsDir()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isDir {
|
||||||
|
out = append(out, browseEntry{Name: name, Path: full, Dir: true})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !wantFiles {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
info, err := item.Info()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, browseEntry{Name: name, Path: full, Size: info.Size()})
|
||||||
|
}
|
||||||
|
// 文件夹排前面,同类型按名字排(Windows 上的大小写不敏感)
|
||||||
|
sort.SliceStable(out, func(i, j int) bool {
|
||||||
|
if out[i].Dir != out[j].Dir {
|
||||||
|
return out[i].Dir
|
||||||
|
}
|
||||||
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||||
|
})
|
||||||
|
if len(out) > browseMaxEntries {
|
||||||
|
out, truncated = out[:browseMaxEntries], true
|
||||||
|
}
|
||||||
|
if out == nil {
|
||||||
|
out = []browseEntry{}
|
||||||
|
}
|
||||||
|
return out, parent, truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根位置:Windows 下列盘符,其它平台给几个常用的挂载点
|
||||||
|
func browseRoots() []browseEntry {
|
||||||
|
out := make([]browseEntry, 0, 10)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
for c := 'A'; c <= 'Z'; c++ {
|
||||||
|
drive := string(c) + ":\\"
|
||||||
|
if info, err := os.Stat(drive); err == nil && info.IsDir() {
|
||||||
|
out = append(out, browseEntry{Name: string(c) + ":", Path: drive, Dir: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, dir := range []string{"/", "/home", "/mnt", "/media", "/srv", "/data", "/opt", "/var/lib", "/tmp"} {
|
||||||
|
if info, err := os.Stat(dir); err == nil && info.IsDir() {
|
||||||
|
out = append(out, browseEntry{Name: dir, Path: dir, Dir: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if home, err := os.UserHomeDir(); err == nil {
|
||||||
|
if info, err := os.Stat(home); err == nil && info.IsDir() {
|
||||||
|
out = append(out, browseEntry{Name: "主目录 " + home, Path: home, Dir: true})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// 展开目录,供页面上的「浏览」按钮用
|
||||||
|
// kind=file 时连文件一起列,hidden=1 时显示隐藏项
|
||||||
|
func browseHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := browseResp{
|
||||||
|
Roots: browseRoots(),
|
||||||
|
Entries: []browseEntry{},
|
||||||
|
}
|
||||||
|
|
||||||
|
target := strings.TrimSpace(r.URL.Query().Get("path"))
|
||||||
|
if target == "" {
|
||||||
|
// 没给路径时从程序所在目录开始,多半就是用户想选的地方
|
||||||
|
if wd, err := os.Getwd(); err == nil {
|
||||||
|
target = wd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target != "" {
|
||||||
|
abs, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
resp.Path = target
|
||||||
|
resp.Error = "路径不合法:" + err.Error()
|
||||||
|
} else {
|
||||||
|
resp.Path = abs
|
||||||
|
entries, parent, truncated, err := browseList(abs, r.URL.Query().Get("kind") == "file", r.URL.Query().Get("hidden") == "1")
|
||||||
|
resp.Entries, resp.Parent, resp.Truncated = entries, parent, truncated
|
||||||
|
if err != nil {
|
||||||
|
resp.Error = "打开失败:" + err.Error()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||||
|
lPrintErrf("输出目录列表失败:%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
// 路径选择器相关
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 造一棵目录树:两个名字大小写不同的文件夹、两个文件、一个隐藏文件夹
|
||||||
|
func setupBrowseTree(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, sub := range []string{"beta", "Alpha", ".hidden"} {
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, sub), 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range []string{"b.txt", "a.mp4"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, f), []byte("x"), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseNames(entries []browseEntry) []string {
|
||||||
|
out := make([]string, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out = append(out, e.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func browseHas(entries []browseEntry, name string) bool {
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name == name {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func doBrowseQuery(t *testing.T, v url.Values) browseResp {
|
||||||
|
t.Helper()
|
||||||
|
q := ""
|
||||||
|
if v != nil {
|
||||||
|
q = "?" + v.Encode()
|
||||||
|
}
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
browseHandler(rec, httptest.NewRequest(http.MethodGet, "/api/browse"+q, nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("状态码是 %d,想要 200", rec.Code)
|
||||||
|
}
|
||||||
|
var resp browseResp
|
||||||
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||||
|
t.Fatalf("解析响应失败:%v(%s)", err, rec.Body.String())
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func doBrowse(t *testing.T, path, kind string, hidden bool) browseResp {
|
||||||
|
t.Helper()
|
||||||
|
v := url.Values{}
|
||||||
|
if path != "" {
|
||||||
|
v.Set("path", path)
|
||||||
|
}
|
||||||
|
if kind != "" {
|
||||||
|
v.Set("kind", kind)
|
||||||
|
}
|
||||||
|
if hidden {
|
||||||
|
v.Set("hidden", "1")
|
||||||
|
}
|
||||||
|
return doBrowseQuery(t, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseListDirsOnly(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
entries, parent, truncated, err := browseList(dir, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列目录失败:%v", err)
|
||||||
|
}
|
||||||
|
if truncated {
|
||||||
|
t.Fatal("条目没超上限,不该标成截断")
|
||||||
|
}
|
||||||
|
if parent != filepath.Dir(dir) {
|
||||||
|
t.Fatalf("上级目录不对:%q", parent)
|
||||||
|
}
|
||||||
|
want := []string{"Alpha", "beta"}
|
||||||
|
if len(entries) != len(want) {
|
||||||
|
t.Fatalf("只该列出文件夹:%q", browseNames(entries))
|
||||||
|
}
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Name != want[i] {
|
||||||
|
t.Fatalf("第 %d 项是 %q,想要 %q(文件夹排前面、按名字不区分大小写排)", i, e.Name, want[i])
|
||||||
|
}
|
||||||
|
if !e.Dir || e.Path != filepath.Join(dir, e.Name) {
|
||||||
|
t.Fatalf("条目内容不对:%+v", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseListWithFiles(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
entries, _, _, err := browseList(dir, true, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列目录失败:%v", err)
|
||||||
|
}
|
||||||
|
want := []string{"Alpha", "beta", "a.mp4", "b.txt"}
|
||||||
|
if len(entries) != len(want) {
|
||||||
|
t.Fatalf("条目不对:%q", browseNames(entries))
|
||||||
|
}
|
||||||
|
for i, e := range entries {
|
||||||
|
if e.Name != want[i] {
|
||||||
|
t.Fatalf("第 %d 项是 %q,想要 %q", i, e.Name, want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 选文件时文件夹也要列出来,不然没法点进去
|
||||||
|
if !entries[0].Dir {
|
||||||
|
t.Fatal("选文件时也该列出文件夹")
|
||||||
|
}
|
||||||
|
if entries[2].Dir || entries[2].Size != 1 {
|
||||||
|
t.Fatalf("文件条目不对:%+v", entries[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseListHidden(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
entries, _, _, err := browseList(dir, false, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列目录失败:%v", err)
|
||||||
|
}
|
||||||
|
if browseHas(entries, ".hidden") {
|
||||||
|
t.Fatal("默认不该列出隐藏文件夹")
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _, _, err = browseList(dir, false, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("列目录失败:%v", err)
|
||||||
|
}
|
||||||
|
if !browseHas(entries, ".hidden") {
|
||||||
|
t.Fatalf("开了显示隐藏项就该列出来:%q", browseNames(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseParentAtRoot(t *testing.T) {
|
||||||
|
root := "/"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
root = `C:\`
|
||||||
|
}
|
||||||
|
if got := browseParent(root); got != "" {
|
||||||
|
t.Fatalf("根位置不该有上级:%q", got)
|
||||||
|
}
|
||||||
|
if got := browseParent(filepath.Join(root, "a")); got != root {
|
||||||
|
t.Fatalf("子目录的上级应该是根:%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseRootsNotEmpty(t *testing.T) {
|
||||||
|
if len(browseRoots()) == 0 {
|
||||||
|
t.Fatal("根列表不该是空的,否则用户没法从盘符开始选")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseHandlerDir(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
resp := doBrowse(t, dir, "dir", false)
|
||||||
|
if resp.Error != "" {
|
||||||
|
t.Fatalf("不该报错:%s", resp.Error)
|
||||||
|
}
|
||||||
|
if resp.Path != dir {
|
||||||
|
t.Fatalf("回显的路径不对:%q", resp.Path)
|
||||||
|
}
|
||||||
|
if resp.Parent != filepath.Dir(dir) {
|
||||||
|
t.Fatalf("上级目录不对:%q", resp.Parent)
|
||||||
|
}
|
||||||
|
if len(resp.Roots) == 0 {
|
||||||
|
t.Fatal("根列表不该为空")
|
||||||
|
}
|
||||||
|
if got := browseNames(resp.Entries); len(got) != 2 || got[0] != "Alpha" || got[1] != "beta" {
|
||||||
|
t.Fatalf("条目不对:%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseHandlerFileKind(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
resp := doBrowse(t, dir, "file", false)
|
||||||
|
if resp.Error != "" {
|
||||||
|
t.Fatalf("不该报错:%s", resp.Error)
|
||||||
|
}
|
||||||
|
if !browseHas(resp.Entries, "a.mp4") || !browseHas(resp.Entries, "b.txt") {
|
||||||
|
t.Fatalf("选文件时该列出文件:%q", browseNames(resp.Entries))
|
||||||
|
}
|
||||||
|
for _, e := range resp.Entries {
|
||||||
|
if e.Name == "a.mp4" {
|
||||||
|
if e.Dir {
|
||||||
|
t.Fatal("文件不该被标成文件夹")
|
||||||
|
}
|
||||||
|
if e.Path != filepath.Join(dir, "a.mp4") {
|
||||||
|
t.Fatalf("文件路径不对:%q", e.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseHandlerMissingPath(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
resp := doBrowse(t, filepath.Join(dir, "没有这个目录"), "dir", false)
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Fatal("不存在的目录要给出错误提示,而不是静默返回空列表")
|
||||||
|
}
|
||||||
|
// 打不开的时候仍要能往上走,不然用户就卡在一个不存在的路径里了
|
||||||
|
if resp.Parent != dir {
|
||||||
|
t.Fatalf("错误时也该带上上级目录:%q", resp.Parent)
|
||||||
|
}
|
||||||
|
if len(resp.Entries) != 0 {
|
||||||
|
t.Fatalf("打不开的目录不该有内容:%q", browseNames(resp.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseHandlerPathIsFile(t *testing.T) {
|
||||||
|
dir := setupBrowseTree(t)
|
||||||
|
|
||||||
|
resp := doBrowse(t, filepath.Join(dir, "a.mp4"), "dir", false)
|
||||||
|
if resp.Error == "" {
|
||||||
|
t.Fatal("把文件当目录展开应该报错")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseHandlerEmptyPathUsesWorkDir(t *testing.T) {
|
||||||
|
resp := doBrowse(t, "", "dir", false)
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Skip("取不到工作目录")
|
||||||
|
}
|
||||||
|
if normalizePath(resp.Path) != normalizePath(wd) {
|
||||||
|
t.Fatalf("没给路径时该从程序所在目录开始:%q", resp.Path)
|
||||||
|
}
|
||||||
|
// 程序目录里至少有自己的源码,不该是空的
|
||||||
|
if len(resp.Entries) == 0 {
|
||||||
|
t.Fatal("程序所在目录不该是空的")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,7 @@ type configData struct {
|
|||||||
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
|
MergeDelDanmu bool `json:"mergeDelDanmu"` // 合并完成后删除弹幕文件
|
||||||
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
|
MergeOutputDir string `json:"mergeOutputDir"` // 合并完成后成片移动到该文件夹,留空不移动
|
||||||
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
MergeBitrate int `json:"mergeBitrate"` // 合并成片的目标码率(Kbps),0 表示使用默认质量
|
||||||
|
DanmuFontSize int `json:"danmuFontSize"` // 合并时弹幕文字的字号(头像随字号等比缩放),0 表示跟随弹幕文件
|
||||||
|
|
||||||
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
|
DefaultBitrate int `json:"defaultBitrate"` // 新添加主播的默认下载码率上限(Kbps),0 表示录制码率最高的直播源
|
||||||
|
|
||||||
@@ -360,6 +361,10 @@ func checkConfig() {
|
|||||||
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
|
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
if config.DanmuFontSize < 0 {
|
||||||
|
lPrintErrf("%s里的danmuFontSize不能小于 0(0 表示跟随弹幕文件)", configFile)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
if config.DefaultBitrate < 0 {
|
if config.DefaultBitrate < 0 {
|
||||||
lPrintErrf("%s里的defaultBitrate不能小于 0", configFile)
|
lPrintErrf("%s里的defaultBitrate不能小于 0", configFile)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -406,6 +411,9 @@ func checkConfig() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 上次在合成途中被关掉的话,录播目录和成片目录里会留下临时文件
|
||||||
|
cleanTempFiles(recordSaveDir())
|
||||||
|
cleanTempFiles(mergeSaveDir())
|
||||||
for _, u := range config.UIDs {
|
for _, u := range config.UIDs {
|
||||||
if u.Bitrate < 0 {
|
if u.Bitrate < 0 {
|
||||||
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
|
lPrintErrf("%s里的uid %d的bitrate不能小于 0", configFile, u.UID)
|
||||||
|
|||||||
+75
-2
@@ -11,6 +11,7 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/draw"
|
"image/draw"
|
||||||
|
_ "image/gif"
|
||||||
_ "image/jpeg"
|
_ "image/jpeg"
|
||||||
"image/png"
|
"image/png"
|
||||||
"io"
|
"io"
|
||||||
@@ -20,12 +21,14 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
xdraw "golang.org/x/image/draw"
|
xdraw "golang.org/x/image/draw"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 头像缓存目录名
|
// 头像缓存目录名
|
||||||
@@ -200,6 +203,14 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 弹幕字号可被设置覆盖(0 = 跟随弹幕文件),头像尺寸随字号等比缩放
|
||||||
|
if config.DanmuFontSize > 0 {
|
||||||
|
lPrintf("弹幕字号按设置使用 %d(弹幕文件里是 %d)", config.DanmuFontSize, meta.FontSize)
|
||||||
|
meta.FontSize = config.DanmuFontSize
|
||||||
|
}
|
||||||
|
// 弹道按最终字号统一重排,避免还原弹幕或改字号后同弹道追尾
|
||||||
|
relayoutLanes(records, meta)
|
||||||
|
|
||||||
// 下载并圆形裁切头像
|
// 下载并圆形裁切头像
|
||||||
avatarPaths := make(map[int64]string)
|
avatarPaths := make(map[int64]string)
|
||||||
dir := filepath.Dir(jsonlFile)
|
dir := filepath.Dir(jsonlFile)
|
||||||
@@ -267,6 +278,57 @@ func burnDanmu(jsonlFile, videoFile, outFile string, offset float64) error {
|
|||||||
return ctx.burnChunked(chunks)
|
return ctx.burnChunked(chunks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渲染前按最终字号重排弹道:jsonl 里的 lane/width 是录制或 .ass 还原时算的,
|
||||||
|
// 从原格式 .ass 还原的弹幕没把昵称和头像算进宽度(占位提前放行了同弹道的下一条,
|
||||||
|
// 画面上就追尾叠字),改过字号后更是全对不上。这里用和录制时相同的碰撞公式、
|
||||||
|
// 按「昵称(uid):内容 + 头像」的完整宽度重新分配一遍,保证弹道不重叠。
|
||||||
|
func relayoutLanes(records []danmuRecord, meta danmuMeta) {
|
||||||
|
sort.SliceStable(records, func(i, j int) bool { return records[i].Time < records[j].Time })
|
||||||
|
fs := meta.FontSize
|
||||||
|
show := int64(danmuShowSeconds * 1e9)
|
||||||
|
resX := int64(meta.PlayResX)
|
||||||
|
avW, avGap := avatarSize(fs), avatarGap(fs)
|
||||||
|
// 空弹道用「远古」状态占位:placeLane 的条件是本条出现时间晚于上一条的让位时刻,
|
||||||
|
// 零值下 0 > 0 不成立,第一条弹幕(还原出来的弹幕时间可能正好是 0)会占不上弹道,
|
||||||
|
// 占用状态丢掉后后面的弹幕又会跟着落回空弹道,等于白排。录制时弹幕时间总大于 0,
|
||||||
|
// 所以原逻辑没暴露这个问题。
|
||||||
|
lanes := make([]laneState, laneNum)
|
||||||
|
for i := range lanes {
|
||||||
|
lanes[i] = laneState{appear: math.MinInt64, emerge: math.MinInt64, disappear: math.MinInt64}
|
||||||
|
}
|
||||||
|
for i := range records {
|
||||||
|
r := &records[i]
|
||||||
|
r.Width = textWidth(speakerText(r.Name, r.UID, r.Content), fs)
|
||||||
|
total := int64(avW+avGap) + int64(r.Width)
|
||||||
|
rel := int64(r.Time * 1e9)
|
||||||
|
dt := laneState{
|
||||||
|
appear: rel,
|
||||||
|
emerge: rel + total*show/(resX+total),
|
||||||
|
disappear: rel + show,
|
||||||
|
}
|
||||||
|
left := rel + resX*show/(resX+total)
|
||||||
|
j := placeLane(lanes, dt, left)
|
||||||
|
if j < 0 {
|
||||||
|
// 100 条弹道都还占着:退到最早让出来的那条。录制时是直接不写这条,
|
||||||
|
// 合并时丢一条等于凭空少一句弹幕,压一点也比丢了强。
|
||||||
|
j = earliestLane(lanes)
|
||||||
|
lanes[j] = dt
|
||||||
|
}
|
||||||
|
r.Lane = j
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最早会空出来的弹道行
|
||||||
|
func earliestLane(lanes []laneState) int {
|
||||||
|
best := 0
|
||||||
|
for i := range lanes {
|
||||||
|
if lanes[i].disappear < lanes[best].disappear {
|
||||||
|
best = i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
// 用 ffmpeg 探测媒体文件的时长(秒),探测不到返回 0
|
||||||
func probeDuration(ffmpegFile, path string) float64 {
|
func probeDuration(ffmpegFile, path string) float64 {
|
||||||
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
cmd := exec.Command(ffmpegFile, "-hide_banner", "-i", path)
|
||||||
@@ -687,9 +749,9 @@ func downloadAvatar(uid int64, url, path string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
src, _, err := image.Decode(bytes.NewReader(data))
|
src, err := decodeAvatar(data, resp.Header.Get("Content-Type"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("解析失败(可能是 webp 格式):%v", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(path)
|
out, err := os.Create(path)
|
||||||
@@ -700,6 +762,17 @@ func downloadAvatar(uid int64, url, path string) error {
|
|||||||
return png.Encode(out, circleAvatar(src, avatarCacheSize))
|
return png.Encode(out, circleAvatar(src, avatarCacheSize))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 解码头像图片。AcFun 的头像里混着 gif 和 webp:
|
||||||
|
// gif 由标准库解码,多帧的取第一帧;webp 只支持静态的,动画 webp 会报错,
|
||||||
|
// 这种情况下这个用户就不烧头像,只烧文字。
|
||||||
|
func decodeAvatar(data []byte, contentType string) (image.Image, error) {
|
||||||
|
src, _, err := image.Decode(bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("图片解析不了(Content-Type: %s):%v", contentType, err)
|
||||||
|
}
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
|
||||||
// uid → 头像链接的记忆,包含查不到的结果,避免同一个 uid 反复去问
|
// uid → 头像链接的记忆,包含查不到的结果,避免同一个 uid 反复去问
|
||||||
var avatarURLMemo struct {
|
var avatarURLMemo struct {
|
||||||
sync.Mutex
|
sync.Mutex
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
|||||||
|
|
||||||
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
|
||||||
|
|
||||||
|
`POST /api/config`里的`danmuFontSize`控制合并时弹幕文字的字号(像素),头像按同一比例缩放,填 0 表示沿用弹幕文件里记的字号。渲染前会按最终字号重排弹道:从上游原版`.ass`还原出来的弹幕只按内容宽度占位,而渲染用的是「昵称(uid):内容 + 头像」的完整宽度,占位时间更长,照搬原弹道会追尾叠字;重排按完整宽度重新分配,同一条弹道上不会再有两条弹幕叠在一起。
|
||||||
|
|
||||||
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
|
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -109,3 +111,20 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
|
|||||||
成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。
|
成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。
|
||||||
|
|
||||||
MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。
|
MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。
|
||||||
|
|
||||||
|
`GET /api/browse` 给页面上的`浏览`按钮列目录用。参数:`path`(要展开的目录,不填就用程序当前的工作目录)、`kind=file`(连文件一起列,挑文件时用)、`hidden=1`(连以点开头的隐藏项一起列)。页面上的`浏览`有单选(保存目录、ffmpeg 路径、弹幕文件)和多选(手动合并的视频文件)两种用法,接口每次只负责列一层,多选的勾选状态由页面自己保管,所以可以换着目录勾、跨文件夹攒;填回框里时,录播目录下的文件写文件名、别的文件夹里的写绝对路径。
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"path": "D:\\record", // 当前展开的目录,已转成绝对路径
|
||||||
|
"parent": "D:\\", // 上级目录,已经到根时是空串
|
||||||
|
"roots": [{"name": "C:", "path": "C:\\", "dir": true, "size": 0}],
|
||||||
|
"entries": [{"name": "2026-09-25.mp4", "path": "D:\\record\\2026-09-25.mp4", "dir": false, "size": 123}],
|
||||||
|
"truncated": false, // 条目太多时只返回前 2000 项
|
||||||
|
"error": "" // 打不开时的中文原因
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
文件夹排在文件前面,同类按名字排(不区分大小写)。挑文件时文件夹照样会列出来,不然进不去子目录。`error`非空时仍是 HTTP 200,`entries`是空的,但`parent`会带上——页面据此退到上一级,不会把用户卡在一个打不开的路径里(比如把文件路径当目录展开时)。
|
||||||
|
|
||||||
|
这个接口能看到整台机器的目录,但权限和其它管理接口一样,都是拿到口令即全部权限,没有放宽。
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ func listFiles(dir string) []fileEntry {
|
|||||||
if e.IsDir() {
|
if e.IsDir() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 合成过程中的临时文件(拼接、分块、本地临时成片)不上列表
|
||||||
|
if strings.HasPrefix(e.Name(), chunkTempPrefix) || strings.HasSuffix(e.Name(), partSuffix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
info, err := e.Info()
|
info, err := e.Info()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
|
|||||||
+130
@@ -0,0 +1,130 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// 还原自 .ass 的弹幕:原格式的弹道是按「只有内容」的宽度占位的,渲染用的是
|
||||||
|
// 「昵称(uid):内容 + 头像」的宽度,占位更长 → 同弹道会追尾。重排后必须分开。
|
||||||
|
func TestRelayoutSeparatesRestoredOverlap(t *testing.T) {
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
|
||||||
|
content := "哈哈哈哈哈哈哈哈"
|
||||||
|
// 两条原本同弹道、间隔 2.5 秒:按内容宽度(8×60=480)算,2.5 秒时前一条已经让位;
|
||||||
|
// 按昵称头像宽度(头像48+间隔10+文本720)算,前一条要到约 2.9 秒才让位
|
||||||
|
records := []danmuRecord{
|
||||||
|
{Time: 0, UID: 101, Name: "甲", Content: content, Lane: 0, Width: 480},
|
||||||
|
{Time: 2.5, UID: 202, Name: "乙", Content: content, Lane: 0, Width: 480},
|
||||||
|
}
|
||||||
|
|
||||||
|
relayoutLanes(records, meta)
|
||||||
|
|
||||||
|
if records[0].Lane == records[1].Lane {
|
||||||
|
t.Fatalf("两条追尾的弹幕重排后仍在同一条弹道 %d", records[0].Lane)
|
||||||
|
}
|
||||||
|
// 宽度也要按昵称头像版重算,否则滚动速度还是错的
|
||||||
|
want := textWidth(speakerText("甲", 101, content), meta.FontSize)
|
||||||
|
if records[0].Width != want {
|
||||||
|
t.Errorf("宽度应重算为 %d,实际 %d", want, records[0].Width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 录制时存下来的弹道本来就是按同一套公式算的,重排应当原样保留
|
||||||
|
func TestRelayoutKeepsLiveLayout(t *testing.T) {
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48}
|
||||||
|
var records []danmuRecord
|
||||||
|
for i := 0; i < 40; i++ {
|
||||||
|
records = append(records, danmuRecord{
|
||||||
|
Time: float64(i) * 0.4, UID: int64(100 + i), Name: "观众", Content: "测试弹幕内容",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
relayoutLanes(records, meta)
|
||||||
|
first := make([]int, len(records))
|
||||||
|
for i, r := range records {
|
||||||
|
first[i] = r.Lane
|
||||||
|
}
|
||||||
|
|
||||||
|
relayoutLanes(records, meta)
|
||||||
|
for i, r := range records {
|
||||||
|
if r.Lane != first[i] {
|
||||||
|
t.Fatalf("第 %d 条重排两次结果不一致:%d → %d", i, first[i], r.Lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 输入顺序乱了也要按时间重排,否则弹道占用顺序错乱
|
||||||
|
func TestRelayoutSortsByTime(t *testing.T) {
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 48}
|
||||||
|
shuffled := []danmuRecord{
|
||||||
|
{Time: 5, UID: 1, Name: "甲", Content: "第三条"},
|
||||||
|
{Time: 1, UID: 2, Name: "乙", Content: "第一条"},
|
||||||
|
{Time: 3, UID: 3, Name: "丙", Content: "第二条"},
|
||||||
|
}
|
||||||
|
sorted := []danmuRecord{
|
||||||
|
{Time: 1, UID: 2, Name: "乙", Content: "第一条"},
|
||||||
|
{Time: 3, UID: 3, Name: "丙", Content: "第二条"},
|
||||||
|
{Time: 5, UID: 1, Name: "甲", Content: "第三条"},
|
||||||
|
}
|
||||||
|
relayoutLanes(shuffled, meta)
|
||||||
|
relayoutLanes(sorted, meta)
|
||||||
|
for i := range sorted {
|
||||||
|
if shuffled[i].Lane != sorted[i].Lane {
|
||||||
|
t.Errorf("第 %d 条弹道不一致:%d vs %d", i, shuffled[i].Lane, sorted[i].Lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 字号越小,同一条弹幕在屏幕上停留时占用的时间越短,弹道占用也更宽松
|
||||||
|
func TestRelayoutWidthScalesWithFontSize(t *testing.T) {
|
||||||
|
big := []danmuRecord{{Time: 0, UID: 7, Name: "甲", Content: "短弹幕"}}
|
||||||
|
small := []danmuRecord{{Time: 0, UID: 7, Name: "甲", Content: "短弹幕"}}
|
||||||
|
|
||||||
|
relayoutLanes(big, danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60})
|
||||||
|
relayoutLanes(small, danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 30})
|
||||||
|
|
||||||
|
if big[0].Width <= small[0].Width {
|
||||||
|
t.Errorf("字号 60 的宽度 %d 应大于字号 30 的 %d", big[0].Width, small[0].Width)
|
||||||
|
}
|
||||||
|
if big[0].Width != small[0].Width*2 {
|
||||||
|
t.Errorf("宽度应随字号等比缩放:60 → %d,30 → %d", big[0].Width, small[0].Width)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 密集弹幕(弹道信息全是坏的:lane 都是 0、width 都是 0)重排后不应再有追尾,
|
||||||
|
// 也不该超过弹道行数上限
|
||||||
|
func TestRelayoutNoOverlapOnDenseInput(t *testing.T) {
|
||||||
|
meta := danmuMeta{PlayResX: 1920, PlayResY: 1080, FontSize: 60}
|
||||||
|
texts := []string{"短", "中等长度的一条弹幕", "这是一条很长的弹幕内容用来占位置", "colong"}
|
||||||
|
var records []danmuRecord
|
||||||
|
t0 := 0.0
|
||||||
|
for i := 0; i < 600; i++ {
|
||||||
|
records = append(records, danmuRecord{
|
||||||
|
Time: t0,
|
||||||
|
UID: int64(1000 + i%7),
|
||||||
|
Name: "观众" + string(rune('A'+i%26)),
|
||||||
|
Content: texts[i%len(texts)],
|
||||||
|
Lane: 0,
|
||||||
|
Width: 0,
|
||||||
|
})
|
||||||
|
t0 += 0.05 + float64(i%5)*0.07 // 0.05 ~ 0.33 秒一条
|
||||||
|
}
|
||||||
|
|
||||||
|
relayoutLanes(records, meta)
|
||||||
|
|
||||||
|
show := int64(danmuShowSeconds * 1e9)
|
||||||
|
resX := int64(meta.PlayResX)
|
||||||
|
type state struct{ emerge int64 }
|
||||||
|
last := map[int]state{}
|
||||||
|
for i, r := range records {
|
||||||
|
if r.Lane < 0 || r.Lane >= laneNum {
|
||||||
|
t.Fatalf("第 %d 条弹道越界:%d", i, r.Lane)
|
||||||
|
}
|
||||||
|
total := int64(avatarSize(meta.FontSize)+avatarGap(meta.FontSize)) + int64(r.Width)
|
||||||
|
if total <= 0 {
|
||||||
|
t.Fatalf("第 %d 条宽度没算出来:%d", i, r.Width)
|
||||||
|
}
|
||||||
|
rel := int64(r.Time * 1e9)
|
||||||
|
emerge := rel + total*show/(resX+total)
|
||||||
|
if prev, ok := last[r.Lane]; ok && rel < prev.emerge {
|
||||||
|
t.Fatalf("第 %d 条和上一条挤在同弹道 %d:出现 %v 早于上一条让位 %v", i, r.Lane, r.Time, float64(prev.emerge)/1e9)
|
||||||
|
}
|
||||||
|
last[r.Lane] = state{emerge}
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-3
@@ -470,17 +470,26 @@ func runManualMerge(plan mergePlan) error {
|
|||||||
if _, err := os.Stat(plan.out); err == nil {
|
if _, err := os.Stat(plan.out); err == nil {
|
||||||
lPrintWarn("成片已存在,将被覆盖:" + plan.out)
|
lPrintWarn("成片已存在,将被覆盖:" + plan.out)
|
||||||
}
|
}
|
||||||
|
// 先在本地(弹幕所在的录播目录)合成,完成后再落到成片目录:
|
||||||
|
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
|
||||||
|
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
|
||||||
|
|
||||||
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
|
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
|
||||||
filepath.Base(video), filepath.Base(jsonl), offset, note)
|
filepath.Base(video), filepath.Base(jsonl), offset, note)
|
||||||
|
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
|
||||||
|
|
||||||
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
|
||||||
markFileActive(plan.out, true)
|
markFileActive(tmpOut, true)
|
||||||
burnErr := burnDanmu(jsonl, video, plan.out, offset)
|
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
|
||||||
markFileActive(plan.out, false)
|
markFileActive(tmpOut, false)
|
||||||
if burnErr != nil {
|
if burnErr != nil {
|
||||||
|
_ = os.Remove(tmpOut)
|
||||||
return burnErr
|
return burnErr
|
||||||
}
|
}
|
||||||
|
if err := placeOutput(tmpOut, plan.out); err != nil {
|
||||||
|
_ = os.Remove(tmpOut)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
lPrintln("手动合并完成:" + plan.out)
|
lPrintln("手动合并完成:" + plan.out)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 合成好的本地临时文件落到成片目录:同盘走改名,本地不该再留文件
|
||||||
|
func TestPlaceOutputMovesFile(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
tmp := filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_测试_弹幕合并.mp4")
|
||||||
|
writeTestFile(t, tmp, "成片内容")
|
||||||
|
out := filepath.Join(mergeSaveDir(), "测试_弹幕合并.mp4")
|
||||||
|
|
||||||
|
if err := placeOutput(tmp, out); err != nil {
|
||||||
|
t.Fatalf("落盘失败:%v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(out)
|
||||||
|
if err != nil || string(got) != "成片内容" {
|
||||||
|
t.Fatalf("成片内容不对:%q %v", got, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(tmp); !os.IsNotExist(err) {
|
||||||
|
t.Error("落盘之后本地临时文件应该已经不在了")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成片目录里的子目录不存在时自动创建,已有同名成片时直接覆盖
|
||||||
|
func TestPlaceOutputCreatesDirAndOverwrites(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
sub := filepath.Join(mergeSaveDir(), "按日期", "2026-09")
|
||||||
|
if err := os.MkdirAll(sub, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out := filepath.Join(sub, "同名_弹幕合并.mp4")
|
||||||
|
writeTestFile(t, out, "上一次的成片")
|
||||||
|
|
||||||
|
tmp := filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_同名_弹幕合并.mp4")
|
||||||
|
writeTestFile(t, tmp, "这一次的成片")
|
||||||
|
|
||||||
|
if err := placeOutput(tmp, out); err != nil {
|
||||||
|
t.Fatalf("落盘失败:%v", err)
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(out)
|
||||||
|
if err != nil || string(got) != "这一次的成片" {
|
||||||
|
t.Fatalf("应该覆盖成新内容,实际:%q %v", got, err)
|
||||||
|
}
|
||||||
|
// 成片目录里不该留 .part
|
||||||
|
entries, _ := os.ReadDir(sub)
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.HasSuffix(e.Name(), partSuffix) {
|
||||||
|
t.Errorf("不该留下 %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地临时文件不存在时报错,而且不在成片目录留下任何东西
|
||||||
|
func TestPlaceOutputMissingSourceLeavesNothing(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
out := filepath.Join(mergeSaveDir(), "没有源文件_弹幕合并.mp4")
|
||||||
|
if err := placeOutput(filepath.Join(recordSaveDir(), "不存在.mp4"), out); err == nil {
|
||||||
|
t.Fatal("源文件不存在时应该报错")
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(mergeSaveDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Name() == filepath.Base(out) || strings.HasSuffix(e.Name(), partSuffix) {
|
||||||
|
t.Errorf("失败之后不该留下 %s", e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时成片放在弹幕文件所在目录(本地),且带临时前缀(否则会出现在文件管理里)
|
||||||
|
func TestTempMergeOutputPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
danmu := filepath.Join(dir, "record", "2026-09-25 01-00-00 甲.jsonl")
|
||||||
|
got := tempMergeOutput(danmu, "2026-09-25 01-00-00 甲_弹幕合并.mp4")
|
||||||
|
want := filepath.Join(dir, "record", chunkTempPrefix+"merging_2026-09-25 01-00-00 甲_弹幕合并.mp4")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("临时路径不对:\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(filepath.Base(got), chunkTempPrefix) {
|
||||||
|
t.Error("临时文件必须带临时前缀,否则会被当成正常文件列出")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动时清理残留的临时文件,正常的录播 / 成片不动
|
||||||
|
func TestCleanTempFiles(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
staleRecord := []string{
|
||||||
|
filepath.Join(recordSaveDir(), chunkTempPrefix+"joined.mp4"),
|
||||||
|
filepath.Join(recordSaveDir(), chunkTempPrefix+"remapped.jsonl"),
|
||||||
|
filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_甲_弹幕合并.mp4"),
|
||||||
|
}
|
||||||
|
staleMerge := filepath.Join(mergeSaveDir(), "甲_弹幕合并.mp4"+partSuffix)
|
||||||
|
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
|
||||||
|
writeTestFile(t, p, "残留")
|
||||||
|
}
|
||||||
|
keep := []string{
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.jsonl"), "弹幕"),
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频"),
|
||||||
|
writeTestFile(t, filepath.Join(mergeSaveDir(), "2026-09-25 01-00-00 甲_弹幕合并.mp4"), "成片"),
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanTempFiles(recordSaveDir())
|
||||||
|
cleanTempFiles(mergeSaveDir())
|
||||||
|
|
||||||
|
for _, p := range append(append([]string{}, staleRecord...), staleMerge) {
|
||||||
|
if _, err := os.Stat(p); !os.IsNotExist(err) {
|
||||||
|
t.Errorf("残留的临时文件应该被清掉:%s", filepath.Base(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, p := range keep {
|
||||||
|
if _, err := os.Stat(p); err != nil {
|
||||||
|
t.Errorf("正常文件不该被删:%s", filepath.Base(p))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 临时文件不出现在文件管理的列表里
|
||||||
|
func TestListFilesHidesTemp(t *testing.T) {
|
||||||
|
setupMergeTest(t)
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "2026-09-25 01-00-00 甲.mp4"), "视频")
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), chunkTempPrefix+"merging_甲_弹幕合并.mp4"), "临时")
|
||||||
|
writeTestFile(t, filepath.Join(recordSaveDir(), "甲_弹幕合并.mp4"+partSuffix), "半成品")
|
||||||
|
|
||||||
|
names := make([]string, 0, 4)
|
||||||
|
for _, f := range listFiles(recordSaveDir()) {
|
||||||
|
names = append(names, f.Name)
|
||||||
|
}
|
||||||
|
if len(names) != 1 || names[0] != "2026-09-25 01-00-00 甲.mp4" {
|
||||||
|
t.Errorf("列表里应该只剩正常录播文件,实际:%v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,9 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -257,3 +259,81 @@ func isRecording(liveID string) bool {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 跨盘搬运成片时的临时后缀:拷一半失败也能一眼看出不是成品
|
||||||
|
const partSuffix = ".acfunlive.part"
|
||||||
|
|
||||||
|
// 合成时的临时成片放在弹幕文件所在目录(本地),
|
||||||
|
// 完成后再落到成片目录 —— 成片目录可能是网络盘或外接盘,直接往里写、
|
||||||
|
// 写到一半失败会在那里留下一个看起来正常的半成品。文件名沿用临时前缀,
|
||||||
|
// 所以不会出现在文件管理的列表里,启动时也会清理残留。
|
||||||
|
func tempMergeOutput(danmuFile, outBase string) string {
|
||||||
|
return filepath.Join(filepath.Dir(danmuFile), chunkTempPrefix+"merging_"+outBase)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 把合成好的本地临时文件落到成片位置:同盘直接改名(瞬时),跨盘退回拷贝
|
||||||
|
func placeOutput(tmp, out string) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(out), 0755); err != nil {
|
||||||
|
return fmt.Errorf("创建成片文件夹失败:%v", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(tmp, out); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// 跨盘时 os.Rename 会失败(Windows 上尤其明确),退回拷贝:
|
||||||
|
// 先拷成 .part 再改名,中途失败也不会在成片目录留下像成品的文件
|
||||||
|
part := out + partSuffix
|
||||||
|
if err := copyFile(tmp, part); err != nil {
|
||||||
|
_ = os.Remove(part)
|
||||||
|
return fmt.Errorf("拷贝到成片目录 %s 失败:%v", filepath.Dir(out), err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(part, out); err != nil {
|
||||||
|
_ = os.Remove(part)
|
||||||
|
return fmt.Errorf("成片改名失败:%v", err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(tmp)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拷贝文件,跨盘搬运成片时用
|
||||||
|
func copyFile(src, dst string) error {
|
||||||
|
in, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer in.Close()
|
||||||
|
out, err := os.Create(dst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := io.Copy(out, in); err != nil {
|
||||||
|
_ = out.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return out.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清掉上次运行残留的合成临时文件。正常结束时都会删掉,这里是兜底:
|
||||||
|
// 程序在合成中途被强杀时,临时文件会留在录播目录或成片目录里。
|
||||||
|
// 注意强杀不会连带杀掉 ffmpeg 子进程,它可能还在写这个文件(Windows 上就删不掉),
|
||||||
|
// 所以删不掉时只提示一句,下次启动会再清一次。
|
||||||
|
func cleanTempFiles(dir string) {
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := e.Name()
|
||||||
|
if !strings.HasPrefix(name, chunkTempPrefix) && !strings.HasSuffix(name, partSuffix) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.Remove(path); err == nil {
|
||||||
|
lPrintln("清理上次留下的临时文件:" + name)
|
||||||
|
} else {
|
||||||
|
lPrintWarnf("临时文件 %s 现在删不掉(多半是上次强杀程序后残留的 ffmpeg 还在写),下次启动会自动清理:%v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none}
|
.dot{width:6px;height:6px;border-radius:50%;background:var(--sub);display:inline-block;flex:none}
|
||||||
.dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
.dot.rec{background:var(--accent);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||||
.dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)}
|
.dot.live{background:var(--ok);box-shadow:0 0 0 3px var(--ok-soft)}
|
||||||
|
.dot.warn{background:var(--bad);box-shadow:0 0 0 3px var(--accent-soft)}
|
||||||
|
/* 有没提交的修改时把保存按钮标出来,手机上光看输入框看不出来 */
|
||||||
|
#saveBtn{position:relative}
|
||||||
|
#saveBtn.dirty{box-shadow:0 0 0 3px var(--accent-soft)}
|
||||||
|
#saveBtn.dirty::after{content:"";position:absolute;top:-4px;right:-4px;width:9px;height:9px;border-radius:50%;background:var(--bad);border:2px solid var(--card)}
|
||||||
/* 分区导航 */
|
/* 分区导航 */
|
||||||
.tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
|
.tabs{max-width:1060px;margin:0 auto;padding:0 14px 10px;display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
|
||||||
.tabs::-webkit-scrollbar{display:none}
|
.tabs::-webkit-scrollbar{display:none}
|
||||||
@@ -150,6 +155,32 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
.mask h3{margin:0 0 6px;font-size:15px}
|
.mask h3{margin:0 0 6px;font-size:15px}
|
||||||
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65}
|
.mask p{margin:0 0 12px;font-size:12px;color:var(--sub);line-height:1.65}
|
||||||
#authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px}
|
#authMsg{font-size:12px;color:var(--bad);min-height:17px;margin-top:8px}
|
||||||
|
/* 路径输入框:手工填和点「浏览」并存 */
|
||||||
|
.pathf{display:flex;gap:8px;align-items:center}
|
||||||
|
.pathf input{flex:1 1 auto;min-width:0;max-width:none}
|
||||||
|
.pathf button{flex:none}
|
||||||
|
/* 路径选择弹窗 */
|
||||||
|
#pick .box{max-width:620px}
|
||||||
|
#pickPath{font-size:12.5px}
|
||||||
|
.roots{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
|
||||||
|
.roots:empty{display:none}
|
||||||
|
.roots button{background:var(--soft);color:var(--text);font-size:12px;padding:4px 10px;border-radius:999px}
|
||||||
|
.roots button:hover{background:var(--soft2)}
|
||||||
|
.plist{max-height:44vh;overflow:auto;border:1px solid var(--line);border-radius:var(--radius-s);background:var(--soft2)}
|
||||||
|
.plist .off{padding:10px}
|
||||||
|
.pi{display:flex;align-items:center;gap:10px;padding:7px 10px;font-size:13px;cursor:pointer}
|
||||||
|
.pi+.pi{border-top:1px solid var(--line)}
|
||||||
|
.pi:hover{background:var(--card)}
|
||||||
|
.pi.on{background:var(--accent-soft);color:var(--accent)}
|
||||||
|
.pi-n{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.pi-dir .pi-n{font-weight:600}
|
||||||
|
.pi-m{flex:none;font-size:11.5px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||||
|
/* 多选模式(选视频这种可以选多个的框):文件条目左边给个勾选框 */
|
||||||
|
.pi-c{display:none;flex:none;width:15px;height:15px;border:1px solid var(--line);border-radius:4px;background:var(--card)}
|
||||||
|
#pick.multi .pi:not(.pi-dir) .pi-c{display:block}
|
||||||
|
#pick.multi .pi.on .pi-c{border-color:var(--accent);background:var(--accent)}
|
||||||
|
#pick.multi .pi.on .pi-c::after{content:"";display:block;width:4px;height:8px;margin:1px 0 0 4px;border:solid #fff;border-width:0 2px 2px 0;transform:rotate(45deg)}
|
||||||
|
.lnk{color:var(--accent);cursor:pointer;text-decoration:underline}
|
||||||
.only-m{display:none}
|
.only-m{display:none}
|
||||||
@media (max-width:700px){
|
@media (max-width:700px){
|
||||||
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
|
/* 手机上标题居中、两侧各留一列等宽(左右各只有一个按钮,视觉上才是正的居中) */
|
||||||
@@ -181,6 +212,7 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
|
<div class="bt"><strong>AcFun 直播录制</strong><span class="head-sub">弹幕 / 视频录制,直播结束后自动合并</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-act">
|
<div class="top-act">
|
||||||
|
<span class="stat only-d" id="dirtyTip" style="display:none"><i class="dot warn"></i>有未保存的修改</span>
|
||||||
<span class="stat" id="topStat"></span>
|
<span class="stat" id="topStat"></span>
|
||||||
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
|
<button id="saveBtn" onclick="saveSettings(this)"><span class="only-d">保存设置</span><span class="only-m">保存</span></button>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,11 +253,16 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
|
<div class="card-h"><h2>保存位置</h2><span class="sub">录播、弹幕和成片都放在这里</span></div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label><input type="text" id="directory" placeholder="例如 D:\record"></div>
|
<div class="f"><label>文件保存目录(留空 = 程序所在目录)</label>
|
||||||
|
<div class="pathf">
|
||||||
|
<input type="text" id="directory" placeholder="例如 D:\record">
|
||||||
|
<button class="ghost" onclick="openPick('directory','dir')">浏览…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
|
<div class="f"><label>录播 / 弹幕子文件夹</label><input type="text" id="recordSubdir" placeholder="record"></div>
|
||||||
<div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
|
<div class="f"><label>成片子文件夹(在保存目录下)</label><input type="text" id="mergeSubdir" placeholder="merged"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">文本框改完点右上角「保存设置」生效,开关和下拉框改完立即生效。</div>
|
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,开关和下拉框改完立即生效;没保存就离开页面或刷新会丢掉改动。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h2>录制参数</h2></div>
|
<div class="card-h"><h2>录制参数</h2></div>
|
||||||
@@ -237,7 +274,12 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<option value="mkv">mkv(可装多音轨)</option>
|
<option value="mkv">mkv(可装多音轨)</option>
|
||||||
</select></div>
|
</select></div>
|
||||||
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
|
<div class="f"><label>默认下载码率上限(Kbps,0 = 最高档)</label><input type="text" id="defaultBitrate" inputmode="numeric" placeholder="例如 2000"></div>
|
||||||
<div class="f"><label>ffmpeg 路径(留空自动查找)</label><input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe"></div>
|
<div class="f"><label>ffmpeg 路径(留空自动查找)</label>
|
||||||
|
<div class="pathf">
|
||||||
|
<input type="text" id="ffmpegPath" placeholder="例如 D:\ffmpeg\bin\ffmpeg.exe">
|
||||||
|
<button class="ghost" onclick="openPick('ffmpegPath','file')">浏览…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
|
<div class="hint">录播和弹幕合并都要用 ffmpeg:留空时先查 PATH,再查程序所在目录及其 bin、ffmpeg 子目录。</div>
|
||||||
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
|
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
|
||||||
@@ -259,10 +301,16 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h2>成片输出</h2></div>
|
<div class="card-h"><h2>成片输出</h2></div>
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label><input type="text" id="mergeOutputDir" placeholder="例如 D:\merged"></div>
|
<div class="f"><label>成片移动到(留空 = 保存目录下的成片文件夹)</label>
|
||||||
|
<div class="pathf">
|
||||||
|
<input type="text" id="mergeOutputDir" placeholder="例如 D:\merged">
|
||||||
|
<button class="ghost" onclick="openPick('mergeOutputDir','dir')">浏览…</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
<div class="f"><label>合成码率(Kbps,0 = 默认质量)</label><input type="text" id="mergeBitrate" inputmode="numeric" placeholder="例如 1500"></div>
|
||||||
|
<div class="f"><label>弹幕字号(像素,0 = 跟随弹幕文件)</label><input type="text" id="danmuFontSize" inputmode="numeric" placeholder="例如 42,头像随字号一起缩放"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hint">文本框改完点右上角「保存设置」生效。</div>
|
<div class="hint">文本框改完按回车或点右上角「保存设置」生效,不做这一步的话 3 秒后会被刷回原值。</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div>
|
<div class="card-h"><h2>手动合并</h2><span class="sub">自己挑视频和弹幕合成</span></div>
|
||||||
@@ -271,10 +319,17 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div class="f">
|
<div class="f">
|
||||||
<label>视频文件(一行一个,也可填绝对路径)</label>
|
<label>视频文件(一行一个,也可填绝对路径)</label>
|
||||||
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
|
<textarea id="mrgVideos" rows="3" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.mp4"></textarea>
|
||||||
|
<div class="pathf" style="margin-top:8px">
|
||||||
|
<button class="ghost" onclick="openPick('mrgVideos','file',1)">浏览…</button>
|
||||||
|
<span class="hint" style="margin:0;flex:1 1 auto;min-width:0">可多选,能换文件夹继续勾;选完追加到框里,不清掉已填内容</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="f">
|
<div class="f">
|
||||||
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
|
<label>弹幕文件(.jsonl 直接用,.ass 会先还原成 .jsonl)</label>
|
||||||
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
<div class="pathf">
|
||||||
|
<input type="text" id="mrgDanmu" list="mrgDanmuList" autocomplete="off" spellcheck="false" placeholder="2026-09-25 01-44-57 Xsphere 电脑自己画画.jsonl">
|
||||||
|
<button class="ghost" onclick="openPick('mrgDanmu','file')">浏览…</button>
|
||||||
|
</div>
|
||||||
<datalist id="mrgDanmuList"></datalist>
|
<datalist id="mrgDanmuList"></datalist>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -341,7 +396,7 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div class="hint bad" id="meowBlocked" style="display:none"></div>
|
<div class="hint bad" id="meowBlocked" style="display:none"></div>
|
||||||
<div class="bar">
|
<div class="bar">
|
||||||
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
|
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
|
||||||
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得点右上角「保存设置」</span>
|
<span class="hint">用上面填写的昵称发一条测试推送,不用先保存;改完记得按回车或点右上角「保存设置」</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -394,6 +449,26 @@ const webuiHTML = `<!DOCTYPE html>
|
|||||||
<div id="authMsg"></div>
|
<div id="authMsg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mask" id="pick">
|
||||||
|
<div class="box pbox">
|
||||||
|
<h3 id="pickTitle">选择文件夹</h3>
|
||||||
|
<p id="pickDesc">逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。</p>
|
||||||
|
<div class="pathf" style="margin-bottom:8px">
|
||||||
|
<button class="ghost" id="pickUp" onclick="pickUp()">↑ 上一级</button>
|
||||||
|
<input type="text" id="pickPath" spellcheck="false" autocomplete="off" placeholder="也可以直接填或粘贴路径,回车跳转">
|
||||||
|
<button class="ghost" onclick="pickGo(document.getElementById('pickPath').value)">转到</button>
|
||||||
|
</div>
|
||||||
|
<div class="roots" id="pickRoots"></div>
|
||||||
|
<div class="plist" id="pickList"></div>
|
||||||
|
<div class="hint" id="pickMsg"></div>
|
||||||
|
<div class="bar" style="margin-top:12px">
|
||||||
|
<label class="sw" style="margin-right:auto"><input type="checkbox" id="pickHidden"><span class="sw-i"></span>显示隐藏项</label>
|
||||||
|
<span class="hint" id="pickSel" style="margin:0"></span>
|
||||||
|
<button class="ghost" onclick="closePick()">取消</button>
|
||||||
|
<button id="pickOk" onclick="pickConfirm()">选用这个文件夹</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
<script>
|
||||||
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); };
|
var esc = function(s){ return (s||"").replace(/[&<>"]/g, function(c){ return {"&":"&","<":"<",">":">","\"":"""}[c]; }); };
|
||||||
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
|
var saving = false; // 正在保存设置时暂停定时刷新对表单的覆写
|
||||||
@@ -402,8 +477,52 @@ var logOffset = "0"; // 已经读取到的日志偏移量
|
|||||||
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
|
var token = localStorage.getItem("acfunlive_token") || ""; // 管理口令,存在浏览器本地
|
||||||
var authed = false; // 是否已通过口令校验,未通过时停止轮询
|
var authed = false; // 是否已通过口令校验,未通过时停止轮询
|
||||||
var tokenLoaded = false;
|
var tokenLoaded = false;
|
||||||
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !el.matches(":focus")) el.checked = !!v; }
|
// 设置表单里的字段,元素 id 与 /api/config 的字段名一致
|
||||||
function setVal(id, v){ var el = document.getElementById(id); if(!saving && document.activeElement !== el) el.value = v; }
|
var settingFields = ["directory","autoMerge","mergeDelVideo","mergeDelDanmu","mergeOutputDir","mergeBitrate","danmuFontSize","defaultBitrate","recordSubdir","mergeSubdir","ffmpegPath","recordFormat","notifyEnabled","notifyUrl","notifyToken","notifyGroup","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled","meowNickname","meowMsgType","meowHtmlHeight","meowImgUrl","webExternal"];
|
||||||
|
var numberFields = {mergeBitrate:1, danmuFontSize:1, defaultBitrate:1, meowHtmlHeight:1};
|
||||||
|
// 用户改过、还没提交的字段。定时刷新会拿服务端的值填表单,不记下这些字段的话,
|
||||||
|
// 刚填好的数字 3 秒后会被刷回旧值,看起来就像「改了又自己恢复」
|
||||||
|
var dirty = {};
|
||||||
|
var lastSt = null; // 最近一次 /api/status 的结果,自动保存时用它填没提交的字段
|
||||||
|
function dirtyCount(){ return Object.keys(dirty).length; }
|
||||||
|
function markDirty(id){ if(id && !dirty[id]){ dirty[id] = true; syncDirtyUI(); } }
|
||||||
|
function dropDirty(id){ if(dirty[id]){ delete dirty[id]; syncDirtyUI(); } }
|
||||||
|
function syncDirtyUI(){
|
||||||
|
var n = dirtyCount();
|
||||||
|
var tip = document.getElementById("dirtyTip");
|
||||||
|
if(tip) tip.style.display = n ? "" : "none";
|
||||||
|
var b = document.getElementById("saveBtn");
|
||||||
|
if(b) b.classList.toggle("dirty", n > 0);
|
||||||
|
}
|
||||||
|
// 提交成功后才把对应的未保存标记清掉:值还是提交时那个才清,避免把提交期间的新改动也当成已保存
|
||||||
|
function clearSaved(body){
|
||||||
|
settingFields.forEach(function(id){
|
||||||
|
if(dirty[id] && String(fieldValue(id)).trim() === String(body[id]).trim()) delete dirty[id];
|
||||||
|
});
|
||||||
|
syncDirtyUI();
|
||||||
|
}
|
||||||
|
function fieldValue(id){
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
if(!el) return "";
|
||||||
|
return el.type === "checkbox" ? !!el.checked : el.value;
|
||||||
|
}
|
||||||
|
// 按当前表单拼 /api/config 的请求体;serverForDirty 为真时,没提交的字段用服务端的值顶上
|
||||||
|
// (开关、下拉是改完立即保存的,不该被别的框里填了一半的内容连累到整批失败)
|
||||||
|
function settingsBody(serverForDirty){
|
||||||
|
var body = {};
|
||||||
|
settingFields.forEach(function(id){
|
||||||
|
var v = fieldValue(id);
|
||||||
|
if(typeof v === "string"){
|
||||||
|
v = v.trim();
|
||||||
|
if(numberFields[id]) v = parseInt(v, 10) || 0;
|
||||||
|
}
|
||||||
|
if(serverForDirty && dirty[id] && lastSt && lastSt[id] !== undefined) v = lastSt[id];
|
||||||
|
body[id] = v;
|
||||||
|
});
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
function setChk(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && !el.matches(":focus")) el.checked = !!v; }
|
||||||
|
function setVal(id, v){ var el = document.getElementById(id); if(!saving && !dirty[id] && document.activeElement !== el) el.value = v; }
|
||||||
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
|
function show(el, v){ var e = document.getElementById(el); if(e) e.style.display = v ? "" : "none"; }
|
||||||
// 管理接口都要带上口令
|
// 管理接口都要带上口令
|
||||||
function api(url, opt){
|
function api(url, opt){
|
||||||
@@ -510,6 +629,7 @@ async function refresh(){
|
|||||||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||||||
if(!r.ok) return;
|
if(!r.ok) return;
|
||||||
var st = await r.json();
|
var st = await r.json();
|
||||||
|
lastSt = st;
|
||||||
hideLogin();
|
hideLogin();
|
||||||
// 口令只在登录后拉取一次,填进设置区的只读框
|
// 口令只在登录后拉取一次,填进设置区的只读框
|
||||||
if(!tokenLoaded){
|
if(!tokenLoaded){
|
||||||
@@ -523,6 +643,7 @@ async function refresh(){
|
|||||||
setVal("directory", st.directory||"");
|
setVal("directory", st.directory||"");
|
||||||
setVal("mergeOutputDir", st.mergeOutputDir||"");
|
setVal("mergeOutputDir", st.mergeOutputDir||"");
|
||||||
setVal("mergeBitrate", st.mergeBitrate||0);
|
setVal("mergeBitrate", st.mergeBitrate||0);
|
||||||
|
setVal("danmuFontSize", st.danmuFontSize||0);
|
||||||
setVal("defaultBitrate", st.defaultBitrate||0);
|
setVal("defaultBitrate", st.defaultBitrate||0);
|
||||||
setVal("recordSubdir", st.recordSubdir||"record");
|
setVal("recordSubdir", st.recordSubdir||"record");
|
||||||
setVal("mergeSubdir", st.mergeSubdir||"merged");
|
setVal("mergeSubdir", st.mergeSubdir||"merged");
|
||||||
@@ -583,7 +704,17 @@ async function refresh(){
|
|||||||
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
// 数据没变化不重建;正在列表里输入时也跳过,避免把没提交的输入清掉
|
||||||
if(sig !== stSig && !list.contains(document.activeElement)){
|
if(sig !== stSig && !list.contains(document.activeElement)){
|
||||||
stSig = sig;
|
stSig = sig;
|
||||||
|
// 重建前把用户改过、还没提交的码率记下来,建完再抄回去(填错的不用留)
|
||||||
|
var keepBr = {};
|
||||||
|
Array.prototype.forEach.call(list.querySelectorAll("input.br"), function(el){
|
||||||
|
var v = parseInt(el.value.trim(), 10);
|
||||||
|
if(el.dataset.dirty === "1" && !isNaN(v) && v >= 0) keepBr[el.dataset.uid] = el.value;
|
||||||
|
});
|
||||||
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
|
list.innerHTML = rows || '<div class="off">列表为空,在上面填 uid 添加</div>';
|
||||||
|
Object.keys(keepBr).forEach(function(uid){
|
||||||
|
var el = list.querySelector('input.br[data-uid="' + uid + '"]');
|
||||||
|
if(el){ el.value = keepBr[uid]; el.dataset.dirty = "1"; }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
var lr = await api("/api/log?since=" + logOffset);
|
var lr = await api("/api/log?since=" + logOffset);
|
||||||
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
if(lr.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
|
||||||
@@ -615,12 +746,12 @@ function showMsg(ok, text){
|
|||||||
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
|
setTimeout(function(){ if(d.parentNode) d.parentNode.removeChild(d); }, 220);
|
||||||
}, life);
|
}, life);
|
||||||
}
|
}
|
||||||
async function post(url, body){
|
async function post(url, body, errPrefix){
|
||||||
saving = true;
|
saving = true;
|
||||||
try{
|
try{
|
||||||
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
var r = await api(url, {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
|
||||||
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
|
if(r.status === 401){ showLogin("管理口令已失效,请重新输入"); return false; }
|
||||||
if(!r.ok) showMsg(false, await r.text());
|
if(!r.ok) showMsg(false, (errPrefix || "") + await r.text());
|
||||||
return r.ok;
|
return r.ok;
|
||||||
}finally{
|
}finally{
|
||||||
saving = false;
|
saving = false;
|
||||||
@@ -629,50 +760,52 @@ async function post(url, body){
|
|||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function saveSettings(b){
|
async function saveSettings(b, autoSave){
|
||||||
if(b) b.disabled = true;
|
if(b) b.disabled = true;
|
||||||
var ok = await post("/api/config", {
|
var body = settingsBody(!!autoSave);
|
||||||
directory: document.getElementById("directory").value.trim(),
|
var ok = await post("/api/config", body, autoSave ? "保存失败,这次改动没生效:" : "");
|
||||||
autoMerge: document.getElementById("autoMerge").checked,
|
if(ok){
|
||||||
mergeDelVideo: document.getElementById("mergeDelVideo").checked,
|
clearSaved(body);
|
||||||
mergeDelDanmu: document.getElementById("mergeDelDanmu").checked,
|
showMsg(true, "已保存");
|
||||||
mergeOutputDir: document.getElementById("mergeOutputDir").value.trim(),
|
}
|
||||||
mergeBitrate: parseInt(document.getElementById("mergeBitrate").value, 10) || 0,
|
|
||||||
defaultBitrate: parseInt(document.getElementById("defaultBitrate").value, 10) || 0,
|
|
||||||
recordSubdir: document.getElementById("recordSubdir").value.trim(),
|
|
||||||
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
|
|
||||||
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
|
|
||||||
recordFormat: document.getElementById("recordFormat").value,
|
|
||||||
notifyEnabled: document.getElementById("notifyEnabled").checked,
|
|
||||||
notifyUrl: document.getElementById("notifyUrl").value.trim(),
|
|
||||||
notifyToken: document.getElementById("notifyToken").value.trim(),
|
|
||||||
notifyGroup: document.getElementById("notifyGroup").value.trim(),
|
|
||||||
notifyLiveOn: document.getElementById("notifyLiveOn").checked,
|
|
||||||
notifyLiveOff: document.getElementById("notifyLiveOff").checked,
|
|
||||||
notifyRecordStart: document.getElementById("notifyRecordStart").checked,
|
|
||||||
notifyMergeDone: document.getElementById("notifyMergeDone").checked,
|
|
||||||
meowEnabled: document.getElementById("meowEnabled").checked,
|
|
||||||
meowNickname: document.getElementById("meowNickname").value.trim(),
|
|
||||||
meowMsgType: document.getElementById("meowMsgType").value,
|
|
||||||
meowHtmlHeight: parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0,
|
|
||||||
meowImgUrl: document.getElementById("meowImgUrl").value.trim(),
|
|
||||||
webExternal: document.getElementById("webExternal").checked
|
|
||||||
});
|
|
||||||
if(ok) showMsg(true, "已保存");
|
|
||||||
if(b) b.disabled = false;
|
if(b) b.disabled = false;
|
||||||
}
|
}
|
||||||
// 勾选类设置修改后立即保存,避免被定时刷新还原
|
// 勾选类设置修改后立即保存,避免被定时刷新还原
|
||||||
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
|
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
|
||||||
document.getElementById(id).addEventListener("change", function(){ saveSettings(null); });
|
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
|
||||||
});
|
});
|
||||||
// 下拉框改完也立即保存
|
// 下拉框改完也立即保存
|
||||||
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); });
|
["recordFormat","meowMsgType"].forEach(function(id){
|
||||||
|
document.getElementById(id).addEventListener("change", function(){ dropDirty(id); saveSettings(null, true); });
|
||||||
|
});
|
||||||
// HTML 高度只对 html 类型有意义,选 text 时收起来
|
// HTML 高度只对 html 类型有意义,选 text 时收起来
|
||||||
function toggleMeowHeight(){
|
function toggleMeowHeight(){
|
||||||
var isHTML = document.getElementById("meowMsgType").value === "html";
|
var isHTML = document.getElementById("meowMsgType").value === "html";
|
||||||
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
|
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
|
||||||
}
|
}
|
||||||
document.getElementById("meowMsgType").addEventListener("change", function(){ toggleMeowHeight(); saveSettings(null); });
|
document.getElementById("meowMsgType").addEventListener("change", toggleMeowHeight);
|
||||||
|
// 文本类字段一改就记成「未保存」,定时刷新不再盖掉它,直到保存成功
|
||||||
|
document.addEventListener("input", function(e){
|
||||||
|
var el = e.target;
|
||||||
|
if(!el) return;
|
||||||
|
if(el.classList && el.classList.contains("br")){ el.dataset.dirty = "1"; return; }
|
||||||
|
if(el.id && settingFields.indexOf(el.id) >= 0) markDirty(el.id);
|
||||||
|
});
|
||||||
|
// 回车等于点保存:输入框不在 form 里,浏览器默认不会提交,很容易以为已经生效了
|
||||||
|
document.addEventListener("keydown", function(e){
|
||||||
|
if(e.key !== "Enter" || e.altKey || e.ctrlKey || e.metaKey || e.isComposing) return;
|
||||||
|
var el = e.target;
|
||||||
|
if(!el || el.tagName !== "INPUT" || el.type !== "text" || el.readOnly) return;
|
||||||
|
if(el.classList.contains("br")){ el.blur(); return; } // 码率失焦即提交
|
||||||
|
if(el.id === "newuid"){ e.preventDefault(); addUid(); return; }
|
||||||
|
if(el.id === "authTokenNew"){ e.preventDefault(); changeToken(); return; }
|
||||||
|
if(el.id === "mrgOffset" || el.id === "mrgOut" || el.id === "mrgDanmu"){ e.preventDefault(); checkMerge(); return; }
|
||||||
|
if(settingFields.indexOf(el.id) >= 0){ e.preventDefault(); saveSettings(null); }
|
||||||
|
});
|
||||||
|
// 还有没保存的改动时提醒一句,别直接关掉页面
|
||||||
|
window.addEventListener("beforeunload", function(e){
|
||||||
|
if(dirtyCount() > 0){ e.preventDefault(); e.returnValue = ""; return ""; }
|
||||||
|
});
|
||||||
var fileList = [];
|
var fileList = [];
|
||||||
function fmtSize(n){
|
function fmtSize(n){
|
||||||
if(!n) return "0 B";
|
if(!n) return "0 B";
|
||||||
@@ -699,6 +832,7 @@ async function loadFiles(b){
|
|||||||
if(!r.ok){ showMsg(false, await r.text()); return; }
|
if(!r.ok){ showMsg(false, await r.text()); return; }
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
fileList = d.files || [];
|
fileList = d.files || [];
|
||||||
|
recDir = d.recordDir || "";
|
||||||
fillMergePickers();
|
fillMergePickers();
|
||||||
document.getElementById("diskInfo").textContent =
|
document.getElementById("diskInfo").textContent =
|
||||||
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
|
"磁盘剩余 " + fmtSize(d.free) + " / 共 " + fmtSize(d.total) +
|
||||||
@@ -735,6 +869,192 @@ async function delFile(i){
|
|||||||
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
|
showMsg(!!(r && r.ok), (r && r.ok) ? "已删除" : ("删除失败:" + (r ? await r.text() : "连接失败")));
|
||||||
loadFiles();
|
loadFiles();
|
||||||
}
|
}
|
||||||
|
// ========== 路径选择器 ==========
|
||||||
|
// 目录 / 文件框两种填法并存:自己敲或粘贴,或者点「浏览…」逐级点选
|
||||||
|
var pickField = ""; // 选中的路径填回哪个输入框
|
||||||
|
var pickKind = "dir"; // dir 选文件夹,file 选文件
|
||||||
|
var pickCur = ""; // 当前所在目录
|
||||||
|
var pickParent = ""; // 上一级
|
||||||
|
var pickFile = ""; // 单选时选中的那个
|
||||||
|
var pickMulti = false; // 多选模式(视频框这种一行一个、可以选多个的)
|
||||||
|
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
|
||||||
|
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
|
||||||
|
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
|
||||||
|
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
|
||||||
|
|
||||||
|
function openPick(id, kind, multi){
|
||||||
|
var el = document.getElementById(id);
|
||||||
|
pickField = id;
|
||||||
|
pickKind = kind === "file" ? "file" : "dir";
|
||||||
|
pickMulti = !!multi && pickKind === "file";
|
||||||
|
pickFile = "";
|
||||||
|
pickSel = [];
|
||||||
|
document.getElementById("pickTitle").textContent = pickMulti ? "选择文件(可多选)" : (pickKind === "file" ? "选择文件" : "选择文件夹");
|
||||||
|
document.getElementById("pickDesc").textContent = pickMulti
|
||||||
|
? "点一下文件名即勾选,再点一下取消;可以进到别的文件夹继续勾。选好后点下面的按钮追加到框里,已经填好的内容不会被清掉。"
|
||||||
|
: "逐级点选即可;也可以直接在地址栏里填或粘贴路径后按回车。选好后点下面的按钮填进设置框。";
|
||||||
|
document.getElementById("pickHidden").checked = false;
|
||||||
|
document.getElementById("pick").classList.toggle("multi", pickMulti);
|
||||||
|
document.getElementById("pick").classList.add("on");
|
||||||
|
// 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录
|
||||||
|
var start = el ? el.value.split("\n")[0].trim() : "";
|
||||||
|
// 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始
|
||||||
|
if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || id === "mrgVideos") ? recDir : "";
|
||||||
|
pickGo(start);
|
||||||
|
}
|
||||||
|
function closePick(){ document.getElementById("pick").classList.remove("on"); }
|
||||||
|
function pickUp(){ if(pickParent) pickGo(pickParent); }
|
||||||
|
async function pickGo(path){
|
||||||
|
lastNavAt = Date.now();
|
||||||
|
var msg = document.getElementById("pickMsg");
|
||||||
|
msg.textContent = "";
|
||||||
|
var base = "/api/browse?kind=" + pickKind + (document.getElementById("pickHidden").checked ? "&hidden=1" : "");
|
||||||
|
var d = null, fellBack = false;
|
||||||
|
// 框里填的是文件、或者目录根本不存在时,往上退到能打开的一层,别停在空列表上
|
||||||
|
for(var i = 0; i < 6; i++){
|
||||||
|
var url = base + (path ? "&path=" + encodeURIComponent(path) : "");
|
||||||
|
var r = null;
|
||||||
|
try{ r = await api(url); }catch(e){}
|
||||||
|
if(!r){ msg.textContent = "读取失败,连接不上服务器"; return; }
|
||||||
|
if(r.status === 401){ closePick(); showLogin("管理口令已失效,请重新输入"); return; }
|
||||||
|
if(!r.ok){ msg.textContent = "读取失败:" + await r.text(); return; }
|
||||||
|
d = await r.json();
|
||||||
|
if(!d.error || !d.parent) break;
|
||||||
|
path = d.parent;
|
||||||
|
fellBack = true;
|
||||||
|
}
|
||||||
|
var items = d.entries || [];
|
||||||
|
pickCur = d.path || "";
|
||||||
|
pickParent = d.parent || "";
|
||||||
|
// 单选时换了目录、之前选中的文件就不在当前视图里了,清掉;多选是故意跨目录攒的,不动
|
||||||
|
if(!pickMulti && pickFile && !items.some(function(x){ return x.path === pickFile; })) pickFile = "";
|
||||||
|
document.getElementById("pickPath").value = pickCur;
|
||||||
|
document.getElementById("pickUp").disabled = !pickParent;
|
||||||
|
document.getElementById("pickRoots").innerHTML = (d.roots || []).map(function(x){
|
||||||
|
return '<button type="button" data-path="' + esc(x.path) + '">' + esc(x.name) + '</button>';
|
||||||
|
}).join("");
|
||||||
|
var list = document.getElementById("pickList");
|
||||||
|
if(!items.length){
|
||||||
|
list.innerHTML = '<div class="off">' + (pickKind === "file" ? "这里没有可选项" : "这里没有子文件夹") + '</div>';
|
||||||
|
}else{
|
||||||
|
list.innerHTML = items.map(function(x){
|
||||||
|
var on = "";
|
||||||
|
if(!x.dir){
|
||||||
|
on = (pickMulti ? pickSel.indexOf(x.path) >= 0 : x.path === pickFile) ? " on" : "";
|
||||||
|
}
|
||||||
|
return '<div class="pi' + (x.dir ? " pi-dir" : "") + on + '" data-path="' + esc(x.path) + '" data-dir="' + (x.dir ? "1" : "0") + '">'
|
||||||
|
+ '<span class="pi-c"></span>'
|
||||||
|
+ '<span class="pi-n" title="' + esc(x.path) + '">' + esc(x.name) + '</span>'
|
||||||
|
+ '<span class="pi-m">' + (x.dir ? "文件夹" : fmtSize(x.size)) + '</span></div>';
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
updatePickBar();
|
||||||
|
if(d.error){
|
||||||
|
msg.textContent = d.error;
|
||||||
|
}else if(fellBack){
|
||||||
|
msg.textContent = "原来填的位置打不开,已经跳到能打开的上级目录";
|
||||||
|
}else if(d.truncated){
|
||||||
|
msg.textContent = "条目太多,只列出了前 2000 项";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 按钮文案和「已选 N 项」都跟着当前模式与勾选情况走
|
||||||
|
function updatePickBar(){
|
||||||
|
var ok = document.getElementById("pickOk");
|
||||||
|
var sel = document.getElementById("pickSel");
|
||||||
|
if(pickMulti){
|
||||||
|
ok.disabled = !pickSel.length;
|
||||||
|
ok.textContent = pickSel.length ? ("选用这 " + pickSel.length + " 个文件") : "选用选中的文件";
|
||||||
|
sel.innerHTML = pickSel.length
|
||||||
|
? ("已选 " + pickSel.length + " 项(<span class=\"lnk\" onclick=\"pickClearSel()\">清空</span>)")
|
||||||
|
: "点文件名即勾选,可换目录继续勾";
|
||||||
|
}else{
|
||||||
|
ok.disabled = pickKind === "file" && !pickFile;
|
||||||
|
ok.textContent = pickKind === "file" ? "选用这个文件" : "选用这个文件夹";
|
||||||
|
sel.innerHTML = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 只重画高亮,不重新拉目录
|
||||||
|
function paintPickSel(){
|
||||||
|
var all = document.querySelectorAll("#pickList .pi");
|
||||||
|
for(var i = 0; i < all.length; i++){
|
||||||
|
var it = all[i];
|
||||||
|
if(it.dataset.dir === "1") continue;
|
||||||
|
it.classList.toggle("on", pickMulti ? pickSel.indexOf(it.dataset.path) >= 0 : it.dataset.path === pickFile);
|
||||||
|
}
|
||||||
|
updatePickBar();
|
||||||
|
}
|
||||||
|
function pickClearSel(){ pickSel = []; paintPickSel(); }
|
||||||
|
// 录播目录下的文件就只填文件名,框里清爽,保存目录以后改了也还认得;别处的填绝对路径
|
||||||
|
function pickFillValue(p){
|
||||||
|
if(!recDir) return p;
|
||||||
|
var parent = p.replace(/[\\/][^\\/]*$/, "");
|
||||||
|
if(parent.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === recDir.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase()){
|
||||||
|
return p.split(/[\\/]/).pop();
|
||||||
|
}
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
function pickConfirm(){
|
||||||
|
var el = document.getElementById(pickField);
|
||||||
|
if(pickMulti){
|
||||||
|
if(!pickSel.length) return;
|
||||||
|
if(el){
|
||||||
|
// 追加:已有行原样留着,只把这次勾的加进去(同名/同路径不重复)
|
||||||
|
var lines = el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
|
||||||
|
pickSel.forEach(function(p){
|
||||||
|
var name = pickFillValue(p);
|
||||||
|
if(lines.indexOf(name) < 0 && lines.indexOf(p) < 0) lines.push(name);
|
||||||
|
});
|
||||||
|
el.value = lines.join("\n");
|
||||||
|
// 手动合并的视频/弹幕框不进配置,标了「未保存」也永远清不掉,所以只对设置表单里的框标记
|
||||||
|
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||||||
|
}
|
||||||
|
closePick();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var v = pickKind === "file" ? pickFile : pickCur;
|
||||||
|
if(!v) return;
|
||||||
|
if(pickKind === "file") v = pickFillValue(v);
|
||||||
|
if(el){
|
||||||
|
el.value = v;
|
||||||
|
if(settingFields.indexOf(pickField) >= 0) markDirty(pickField);
|
||||||
|
}
|
||||||
|
closePick();
|
||||||
|
}
|
||||||
|
document.getElementById("pickList").addEventListener("click", function(e){
|
||||||
|
var it = e.target.closest(".pi");
|
||||||
|
if(!it) return;
|
||||||
|
if(Date.now() - lastNavAt < 350) return;
|
||||||
|
// 双击时浏览器会先发两次 click,同一条目短时间内重复点击只算一次
|
||||||
|
if(it.dataset.path === lastPickKey && Date.now() - lastPickAt < 350) return;
|
||||||
|
lastPickKey = it.dataset.path;
|
||||||
|
lastPickAt = Date.now();
|
||||||
|
if(it.dataset.dir === "1"){ pickGo(it.dataset.path); return; }
|
||||||
|
if(pickMulti){
|
||||||
|
var i = pickSel.indexOf(it.dataset.path);
|
||||||
|
if(i >= 0) pickSel.splice(i, 1); else pickSel.push(it.dataset.path);
|
||||||
|
}else{
|
||||||
|
pickFile = it.dataset.path;
|
||||||
|
}
|
||||||
|
paintPickSel();
|
||||||
|
});
|
||||||
|
// 文件双击直接选用(多选时把双击的这个也算上,再连同已勾的一起追加)
|
||||||
|
document.getElementById("pickList").addEventListener("dblclick", function(e){
|
||||||
|
var it = e.target.closest(".pi");
|
||||||
|
if(!it || it.dataset.dir === "1") return;
|
||||||
|
if(pickMulti && pickSel.indexOf(it.dataset.path) < 0) pickSel.push(it.dataset.path);
|
||||||
|
pickConfirm();
|
||||||
|
});
|
||||||
|
document.getElementById("pickRoots").addEventListener("click", function(e){
|
||||||
|
var b = e.target.closest("button");
|
||||||
|
if(b) pickGo(b.dataset.path);
|
||||||
|
});
|
||||||
|
document.getElementById("pickHidden").addEventListener("change", function(){ pickGo(pickCur); });
|
||||||
|
document.getElementById("pickPath").addEventListener("keydown", function(e){
|
||||||
|
if(e.key === "Enter"){ e.preventDefault(); pickGo(this.value.trim()); }
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", function(e){
|
||||||
|
if(e.key === "Escape" && document.getElementById("pick").classList.contains("on")) closePick();
|
||||||
|
});
|
||||||
// ========== 手动合并 ==========
|
// ========== 手动合并 ==========
|
||||||
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
|
var mrgBusy = false; // 是否正在合并,跟着 /api/status 一起刷新
|
||||||
function mrgPayload(){
|
function mrgPayload(){
|
||||||
@@ -879,24 +1199,34 @@ async function delUid(uid){
|
|||||||
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
if(!confirm("确定删除 uid " + uid + " 并停止其录制?")) return;
|
||||||
post("/api/streamer/remove", {uid: uid});
|
post("/api/streamer/remove", {uid: uid});
|
||||||
}
|
}
|
||||||
document.addEventListener("change", function(e){
|
// 码率填错时把框里的内容恢复成服务端记录的值,不能干等下一次列表重建
|
||||||
|
function restoreBr(el){
|
||||||
|
var uid = parseInt(el.dataset.uid, 10);
|
||||||
|
var cur = (lastSt && lastSt.streamers ? lastSt.streamers : []).filter(function(s){ return s.uid === uid; })[0];
|
||||||
|
delete el.dataset.dirty;
|
||||||
|
el.value = cur ? (cur.bitrate || 0) : 0;
|
||||||
|
}
|
||||||
|
document.addEventListener("change", async function(e){
|
||||||
var t = e.target;
|
var t = e.target;
|
||||||
if(t.matches('input[type=checkbox][data-uid]')){
|
if(t.matches('input[type=checkbox][data-uid]')){
|
||||||
var uid = parseInt(t.dataset.uid, 10);
|
var uid = parseInt(t.dataset.uid, 10);
|
||||||
var row = t.closest(".row");
|
var row = t.closest(".row");
|
||||||
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
var danmu = row.querySelector('input[data-k=danmu]').checked;
|
||||||
var record = row.querySelector('input[data-k=record]').checked;
|
var record = row.querySelector('input[data-k=record]').checked;
|
||||||
var bitrate = parseInt(row.querySelector('input.br').value, 10) || 0;
|
var brEl = row.querySelector('input.br');
|
||||||
post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate});
|
var bitrate = parseInt(brEl.value, 10) || 0;
|
||||||
|
// 勾选时会把同一行的码率一起提交,提交成功后就不算「未提交的输入」了
|
||||||
|
if(await post("/api/streamer", {uid: uid, danmu: danmu, record: record, bitrate: bitrate})) delete brEl.dataset.dirty;
|
||||||
}
|
}
|
||||||
if(t.matches('input.br')){
|
if(t.matches('input.br')){
|
||||||
var uid2 = parseInt(t.dataset.uid, 10);
|
var uid2 = parseInt(t.dataset.uid, 10);
|
||||||
var row2 = t.closest(".row");
|
var row2 = t.closest(".row");
|
||||||
var v = parseInt(t.value.trim(), 10);
|
var v = parseInt(t.value.trim(), 10);
|
||||||
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); refresh(); return; }
|
if(isNaN(v) || v < 0){ showMsg(false, "码率必须是大于等于 0 的数字"); restoreBr(t); return; }
|
||||||
var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
|
var danmu2 = row2.querySelector('input[data-k=danmu]').checked;
|
||||||
var record2 = row2.querySelector('input[data-k=record]').checked;
|
var record2 = row2.querySelector('input[data-k=record]').checked;
|
||||||
post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v});
|
// 提交成功才能把「未提交」标记去掉,列表重建时才不会把它当成用户还在填
|
||||||
|
if(await post("/api/streamer", {uid: uid2, danmu: danmu2, record: record2, bitrate: v})) delete t.dataset.dirty;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// 回到地址栏或上次看的那个分区
|
// 回到地址栏或上次看的那个分区
|
||||||
@@ -933,6 +1263,7 @@ type webStatus struct {
|
|||||||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||||||
MergeOutputDir string `json:"mergeOutputDir"`
|
MergeOutputDir string `json:"mergeOutputDir"`
|
||||||
MergeBitrate int `json:"mergeBitrate"`
|
MergeBitrate int `json:"mergeBitrate"`
|
||||||
|
DanmuFontSize int `json:"danmuFontSize"`
|
||||||
DefaultBitrate int `json:"defaultBitrate"`
|
DefaultBitrate int `json:"defaultBitrate"`
|
||||||
WebExternal bool `json:"webExternal"`
|
WebExternal bool `json:"webExternal"`
|
||||||
RecordSubdir string `json:"recordSubdir"`
|
RecordSubdir string `json:"recordSubdir"`
|
||||||
@@ -993,6 +1324,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
MergeDelDanmu: config.MergeDelDanmu,
|
MergeDelDanmu: config.MergeDelDanmu,
|
||||||
MergeOutputDir: config.MergeOutputDir,
|
MergeOutputDir: config.MergeOutputDir,
|
||||||
MergeBitrate: config.MergeBitrate,
|
MergeBitrate: config.MergeBitrate,
|
||||||
|
DanmuFontSize: config.DanmuFontSize,
|
||||||
WebExternal: config.WebExternal,
|
WebExternal: config.WebExternal,
|
||||||
RecordSubdir: config.RecordSubdir,
|
RecordSubdir: config.RecordSubdir,
|
||||||
MergeSubdir: config.MergeSubdir,
|
MergeSubdir: config.MergeSubdir,
|
||||||
@@ -1070,6 +1402,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
MergeDelDanmu bool `json:"mergeDelDanmu"`
|
||||||
MergeOutputDir string `json:"mergeOutputDir"`
|
MergeOutputDir string `json:"mergeOutputDir"`
|
||||||
MergeBitrate int `json:"mergeBitrate"`
|
MergeBitrate int `json:"mergeBitrate"`
|
||||||
|
DanmuFontSize int `json:"danmuFontSize"`
|
||||||
DefaultBitrate int `json:"defaultBitrate"`
|
DefaultBitrate int `json:"defaultBitrate"`
|
||||||
WebExternal bool `json:"webExternal"`
|
WebExternal bool `json:"webExternal"`
|
||||||
RecordSubdir string `json:"recordSubdir"`
|
RecordSubdir string `json:"recordSubdir"`
|
||||||
@@ -1107,6 +1440,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest)
|
http.Error(w, "mergeBitrate 不能小于 0", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if req.DanmuFontSize < 0 {
|
||||||
|
http.Error(w, "danmuFontSize 不能小于 0(0 表示跟随弹幕文件)", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if req.DefaultBitrate < 0 {
|
if req.DefaultBitrate < 0 {
|
||||||
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
|
http.Error(w, "defaultBitrate 不能小于 0", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -1175,6 +1512,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
config.MergeDelDanmu = req.MergeDelDanmu
|
config.MergeDelDanmu = req.MergeDelDanmu
|
||||||
config.MergeOutputDir = req.MergeOutputDir
|
config.MergeOutputDir = req.MergeOutputDir
|
||||||
config.MergeBitrate = req.MergeBitrate
|
config.MergeBitrate = req.MergeBitrate
|
||||||
|
config.DanmuFontSize = req.DanmuFontSize
|
||||||
config.DefaultBitrate = req.DefaultBitrate
|
config.DefaultBitrate = req.DefaultBitrate
|
||||||
restartWeb := config.WebExternal != req.WebExternal
|
restartWeb := config.WebExternal != req.WebExternal
|
||||||
config.WebExternal = req.WebExternal
|
config.WebExternal = req.WebExternal
|
||||||
@@ -1499,6 +1837,7 @@ func startWebUI(ctx context.Context) {
|
|||||||
mux.HandleFunc("GET /api/log", logHandler)
|
mux.HandleFunc("GET /api/log", logHandler)
|
||||||
mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
|
mux.HandleFunc("POST /api/notify/test", notifyTestHandler)
|
||||||
mux.HandleFunc("GET /api/files", filesHandler)
|
mux.HandleFunc("GET /api/files", filesHandler)
|
||||||
|
mux.HandleFunc("GET /api/browse", browseHandler)
|
||||||
mux.HandleFunc("GET /api/file", fileDownloadHandler)
|
mux.HandleFunc("GET /api/file", fileDownloadHandler)
|
||||||
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
|
mux.HandleFunc("POST /api/file/remove", fileRemoveHandler)
|
||||||
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
|
mux.HandleFunc("POST /api/merge/info", mergeInfoHandler)
|
||||||
|
|||||||
Reference in New Issue
Block a user