Compare commits

...
5 Commits
Author SHA1 Message Date
tamakyi dadd3f7cea 更新 go.mod 2026-10-22 02:15:13 +08:00
tamakyi 40f4f36aa6 自动合并按文件实际位置找弹幕,修掉「jsonl 文件不存在」
为什么:

录制结束时 moveFile 已经把 .ass / .nick.ass / .jsonl 搬到保存目录,但合并时只有视频
分段走了 findMovedFile 兜底,弹幕 jsonl 一直拿着录制时算出来的旧路径。于是文件明明
安安稳稳躺在保存目录里,burnDanmu 却去旧位置找,自动合并以「jsonl 文件不存在」告终
并推送一条失败通知。

连带还有两处也指向错误位置:tempMergeOutput 用 filepath.Dir(jsonlFile) 决定中间成片
写到哪个目录,mergeDelDanmu 按它去删那三个弹幕文件。

处理方式是新增 resolveAfterMove(path)——找不着就退回原路径,这样报错还能说清本来想
找哪儿——然后在两处归一:prepareForMerge 让返回的 jsonl 是解析后的路径;autoMergeJob
里也要归一,因为中间成片写到哪、事后删哪几个文件都看这条路径。

上一个提交(录制直接写保存目录)落地后这条会退化成空操作,文件压根不再挪窝;留着它
是为了兜住「运行中改了保存目录」「老录播补合并」这类场景。

验证:

- 新增 automerge_test.go 两条:prepareForMerge 返回的视频与 jsonl 都从录制位置解析到
  保存位置;目标目录里没有同名文件时退回原路径而不是返回空串。
- go test ./... 23.6s 与 go test -race ./... 26.4s 均全绿,215 个顶层用例。
- 反向验证新增一项:删掉 prepareForMerge 里那句归一,对应用例确实变红。
- 验证边界说明:测到的是路径解析这一层,没有构造一整场录制跑真 ffmpeg 走完合并流程
  ——那需要一整套直播素材,成本远高于收益。
2026-09-28 02:04:10 +08:00
tamakyi 2b1705a970 保存目录不存在时给一句人话再退出,不再 panic 崩进程
为什么:

checkConfig() 里检查 directory / mergeOutputDir 用的是 os.Stat + checkErr(err)。
目录不在时(盘没插、路径拼错)前者失败会直接 panic,打印一屏 goroutine 堆栈后崩掉
整个进程,而不是像同函数里其它检查那样给一句提示再 os.Exit(1)。服务被 nssm 之类托管
时崩了还会被反复拉起来,用户看到的只是一堆堆栈。

处理方式是复用原本只给 web 用的 checkWebDir,把它提升成通用的 checkDirField 并搬到
config.go:启动读配置、web 改设置、转码排队从此共用同一份实现,不会有两份各自漂移的
写法。文案沿用原来 IsDir 分支的措辞(「X 必须是存在的文件夹:路径」),只是不再依赖
os.Stat 先成功,且空串仍然表示「不指定」而不是错误。

验证:

- 实跑对比。修前 directory 指向不存在的目录:
  panic: GetFileAttributesEx ...: The system cannot find the file specified.
  修后:[ERROR] config.json里的directory 必须是存在的文件夹:… 退出码 1,
  日志里 panic 出现 0 次。
- 同一个 exe 另验两件事:directory 指向存在的目录能正常启动并建出 record/merged;
  web /api/config 传不存在的目录仍返回 400 + 同一套文案(改名没走样)。
- 新增 2 条测试,其中 TestCheckConfigNeverPanicsOnMissingDir 是静态检查——这条失败
  路径是 os.Exit(1),真跑一遍会把整个测试进程带走,所以改为读 config.go 源码断言
  checkConfig 函数体里不出现 checkErr(,与项目里既有的静态一致性检查同一套路。
- 反向验证新增一项:把两处换回老的 checkErr 写法后,该测试确实变红。
2026-09-28 02:03:56 +08:00
tamakyi 7ab80ec429 录制过程直接写进配置里的保存目录,不再落到程序目录下绕一圈
为什么:

原来存在两个语义几乎相同的目录函数:recordingDir() 只看 -record 命令行参数,
没传就是程序所在目录;recordSaveDir() 才尊重 config.json 里的 directory。
ffmpeg 的实时输出、弹幕 ass/jsonl 的落盘、启动时建目录都走前者,录制结束后
moveFile() 才把文件搬到后者。

后果是:配了 directory 的人会发现录制中的文件一直躺在程序目录里(通常是系统盘),
录完才搬一次,跨盘就等于把整份文件再复制一遍;录制中途中断或者进程崩掉,文件还会
永久留在那儿。根目录 record\、merged\ 里那几个旧产物就是这么留下的。

处理方式是删掉 recordingDir(),四处调用点(启动建目录、两个平台的 transFilename、
web 保存设置后)统一走 recordSaveDir()。没有给旧函数留壳:留两个语义相近的目录函数,
迟早还会再漂一次。

saveRootDir() 依次兜底到 -record 目录、程序所在目录、当前工作目录,绝不返回空串——
空串会让后面 filepath.Join 拼出个相对路径,ffmpeg 就按自己的当前目录去写,文件落到
哪里完全不可控。

顺带订正两处与代码不符的注释:configData.Directory 原本写的是「弹幕文件录制结束后
会被移动到该文件夹」,README 也跟着这么写;现在如实写成保存根目录,并补上优先级。

验证:

- gofmt -l 干净、go vet ./... 干净、Windows 与 GOOS=linux 均编译通过。
- 新增 filename_test.go:锁住 transFilename 生成的路径必然落在 recordSaveDir() 下
  (含 directory 为空时回落 -record 目录),以及 moveFile 在路径一致时不动文件。
- go test ./... 全绿。
- 反向验证:把 recordSaveDir() 退回旧的 recordingDir() 写法后,对应用例确实变红。
- 最小实例端到端:exe 放进单独目录、-record 指向自身、directory 指向别处,
  record\ 与 merged\ 建在 directory 下,程序目录里一个都没有。
2026-09-28 02:03:39 +08:00
tamakyi 3c17503a21 转码的源文件不再限死在录播/成片目录,可以填任意路径
为什么
  转码页的多选框只列 /api/files 的内容,也就是录播目录和成片目录两处;后端的
  resolveTranscodeInput 走 resolveFile,也只认 record/merge 两个目录名。结果是
  录播原片被合并且清掉之后(或者录播目录本来就空),转码页里只剩合并结果可挑,
  磁盘上别处的视频一个都进不来。

怎么改
  - transcodeJobReq 加 Path 字段,与 dir/name 二选一;新增 resolveTranscodeJob()
    把「填了路径就按路径找(认绝对路径和从资源管理器复制来的带引号路径,走
    resolveMergeInput)、否则回落 dir+name」收在一处,两条路最后统一过
    mergeVideoExts 白名单 —— 路径这条能指向磁盘上任何文件,不挡的话手滑把 .jsonl
    丢进来要等 ffmpeg 跑起来才报错。三个调用点和 runTranscode 全部改走它。
  - transcodeProbe 回显绝对路径,页面拿它当任务身份:同名文件在不同目录里不会再
    被认成一个,重复加入也因此挡得住。
  - 转码卡片改成两栏:左边保留原来的文件列表多选,右边加「一行一个路径」的输入框
    和「浏览…」按钮(复用已有的目录选择器,可多选、可跨文件夹跨盘攒,选完只追加不
    覆盖)。openPick 多了个可选的第 5 参数用来指定起始目录,浏览转码文件时从成片
    目录开始(录播目录可能是空的)。
  - 报错前缀改用 transcodeJobReq.label():按路径进来时 Name 是空的,直接拼 j.Name
    会变成「:参数不合法」。

验证
  gofmt -l 干净;go vet 干净;Windows 与 GOOS=linux 编译均通过。
  go test ./... 全绿;go test -race -count=1 ./... 27.2s 全绿;顶层用例 209 个
  (含子测试 226),本次新增 3 个:-run 单独跑过 TestResolveTranscodeJobByPath /
  TestResolveTranscodeJobRejectsBadPath / TestTranscodeProbeHandlerAcceptsPath。
  反向验证 44/44:把 resolveTranscodeJob 的路径分支退回 resolveFile 后,上面两个
  用例确实变红。
  转码接口冒烟 40/40(新增 11 项按路径的:探测回显路径、估算、不存在 400、非视频
  后缀 400、指向文件夹 400、真排队转出成片)。
  页面 jsdom 冒烟 49/49 且无 JS 报错(新增 12 项:路径框存在、空框不给加入、手填能
  进列表并带上绝对路径、探测请求按 path 发、重复加入不重复、浏览弹窗打开/追加不
  覆盖/关闭/清空)。
2026-09-27 11:10:00 +08:00
13 changed files with 489 additions and 91 deletions
+12 -5
View File
@@ -75,7 +75,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系 "qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系
} }
], ],
"directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹 "directory": "", // 录播和弹幕文件的保存根目录,录制时就直接写在这里;留空用运行时的 -record 文件夹
"webPort": 51880, // web UI 的端口,为 0 时不启动 web UI "webPort": 51880, // web UI 的端口,为 0 时不启动 web UI
"autoMerge": false, // 直播结束后自动把弹幕层合并进录播 "autoMerge": false, // 直播结束后自动把弹幕层合并进录播
@@ -127,7 +127,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
档位表要开播后读直播源列表才知道(写在`config.json`的`qualities`里缓存)。没读到过的行,选项后面不会带档位名,旁边的`档位`按钮可以去直播间接一次(主播没开播时会失败并提示,服务端本身要重试三次、约 5 秒,所以点下去会先给一句`正在读取码率档位…`)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。 档位表要开播后读直播源列表才知道(写在`config.json`的`qualities`里缓存)。没读到过的行,选项后面不会带档位名,旁边的`档位`按钮可以去直播间接一次(主播没开播时会失败并提示,服务端本身要重试三次、约 5 秒,所以点下去会先给一句`正在读取码率档位…`)。读档位的时机是:录制开始取流时、页面上点`档位`按钮、以及发现主播在直播但还没有档位缓存时自动读一次(同一个主播 10 秒内只读一次)。录制始终是原画质直接保存(`-c copy`),不会重新编码,所以只能在这几个档位里挑。
录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),两者同名配对才能自动合并;合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。 录播原片和弹幕文件(`.ass`、`.nick.ass`、`.jsonl`)保存在保存目录下的`recordSubdir`子文件夹里(默认`record`),**录制过程中 ffmpeg 就直接写在这里**,不会再先落到别处中转;两者同名配对才能自动合并。保存目录的优先级是:`directory` > 命令行`-record`指定的文件夹 > 程序所在目录。合并后的成片保存在`mergeSubdir`子文件夹里(默认`merged`),留空则用默认值,两个名字不能相同。如果设置了`mergeOutputDir`,成片优先移动到那里。
合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。 合成**先在录播文件夹里做完,写完了才落到成片文件夹**(同一个盘时是直接改名,几乎瞬时;跨盘会自动拷贝):成片文件夹是网络盘、外接盘或者中途断线时,不会在里面留下一个写到一半、看起来却像正常成片的文件。合成失败或中途强杀程序时,临时文件(`acfunlive_chunk_*`、`*.acfunlive.part`)不会出现在文件管理的列表里,程序下次启动会自动清掉。
@@ -169,7 +169,7 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`),
| 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 | | 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 |
| 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 | | 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 |
| 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) | | 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) |
| 转码 | 把录播或成片重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 | | 转码 | 把录播、成片或磁盘上任意位置的视频重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 |
| 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 | | 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 |
| 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 文件 | 磁盘剩余容量、录播和成片的文件列表 |
| 设置 | 是否允许外网访问、管理口令的查看与更换 | | 设置 | 是否允许外网访问、管理口令的查看与更换 |
@@ -271,9 +271,16 @@ web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片
### 转码 ### 转码
`转码`分区用来把录播或成片重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。 `转码`分区用来把视频重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。源文件不限于本程序录的录播和成片,磁盘上任何位置的文件都能丢进来。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。
从文件列表里多选加入之后,每个文件一行,参数各自独立: 源文件有两个入口,挑哪个都行:
- **从文件列表挑**:录播和成片文件夹里的文件列在左边,按 Ctrl 点可以多选,选中后点`加入列表`。
- **自己填路径**:右边的输入框一行一个路径,可以是从资源管理器复制来的绝对路径,也可以是录播/成片文件夹里的文件名(只写文件名就按这两个文件夹找)。点`浏览…`可以从磁盘上任意位置挑,支持多选、可以跨文件夹跨盘攒,选完只往输入框里追加,不会清掉已经填好的。
两个入口进来的文件进的是同一个待转码列表,每条互不影响。
每个文件一行,参数各自独立:
| 参数 | 说明 | | 参数 | 说明 |
| --- | --- | | --- | --- |
+16
View File
@@ -85,6 +85,14 @@ func findMovedFile(path string) string {
return "" return ""
} }
// 收拢到实际位置:找不着时退回原路径,让后面的报错还能指出「本来想找哪儿」
func resolveAfterMove(path string) string {
if p := findMovedFile(path); p != "" {
return p
}
return path
}
// 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。 // 录播因为断流可能分成多段:单段直接使用,多段先拼成完整视频并把弹幕时间映射到拼接后的时间轴。
// 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移、**实际参与合并的源文件**, // 返回合并要用的视频、字幕文件和弹幕相对视频起点的时间偏移、**实际参与合并的源文件**,
// 以及清理临时文件的函数。used 是给「合并完删源视频」用的:中途回退成「只合并第一个分段」时, // 以及清理临时文件的函数。used 是给「合并完删源视频」用的:中途回退成「只合并第一个分段」时,
@@ -92,6 +100,10 @@ func findMovedFile(path string) string {
func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, []string, func(), error) { func prepareForMerge(ffmpegFile string, segs []recordSegment, jsonlFile string, danmuStartNs int64) (string, string, float64, []string, func(), error) {
noop := func() {} noop := func() {}
// 弹幕文件同样会被移动过去。以前只有视频分段走了查找,弹幕还拿着录制时的路径,
// 结果就是视频找得到、弹幕找不到,合并以「jsonl 文件不存在」告终
jsonlFile = resolveAfterMove(jsonlFile)
// 录制结束时文件可能已经被移动到保存目录,逐个找实际位置 // 录制结束时文件可能已经被移动到保存目录,逐个找实际位置
files := make([]string, 0, len(segs)) files := make([]string, 0, len(segs))
starts := make([]int64, 0, len(segs)) starts := make([]int64, 0, len(segs))
@@ -288,6 +300,10 @@ func autoMergeOutFile(jsonlFile string) string {
func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error { func autoMergeJob(uid int, segs []recordSegment, jsonlFile string, danmuStartNs int64) error {
lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层") lPrintln("开始自动合并" + longID(uid) + "的录播和弹幕层")
// 录制结束时弹幕文件可能已被移动到保存目录,先把路径收拢到实际位置:
// 中间成片写到哪、事后删哪几个弹幕文件都按这一条路径来,用旧路径会指向错误的地方
jsonlFile = resolveAfterMove(jsonlFile)
// 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定) // 视频和弹幕是一次录制产生的,文件名去掉扩展名后应该一致(扩展名由 recordFormat 决定)
if len(segs) > 0 { if len(segs) > 0 {
videoBase := filepath.Base(strings.TrimSuffix(segs[0].file, filepath.Ext(segs[0].file))) videoBase := filepath.Base(strings.TrimSuffix(segs[0].file, filepath.Ext(segs[0].file)))
+76
View File
@@ -0,0 +1,76 @@
package main
import (
"os"
"path/filepath"
"testing"
)
// 录制结束时文件会被移动到保存目录,视频分段和弹幕文件都要按新位置找。
// 以前只有视频分段走了查找,弹幕还拿着录制时的路径:视频找到了、弹幕找不到,
// 整场合并以「jsonl 文件不存在」告终(用户看到的就是合成失败 + 一条失败推送)。
func TestPrepareForMergeFindsMovedJsonl(t *testing.T) {
savedConfig := config
defer func() { config = savedConfig }()
root := t.TempDir()
config.Directory = root
config.RecordSubdir = "record"
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
t.Fatal(err)
}
name := "2026-09-27 22-40-42 甲 标题"
// 录制时的位置:文件已经被搬走,这里不该再有
oldDir := t.TempDir()
oldVideo := filepath.Join(oldDir, name+".mp4")
oldJsonl := filepath.Join(oldDir, name+".jsonl")
// 实际位置
newVideo := filepath.Join(recordSaveDir(), name+".mp4")
newJsonl := filepath.Join(recordSaveDir(), name+".jsonl")
for _, p := range []string{newVideo, newJsonl} {
if err := os.WriteFile(p, []byte("{}\n"), 0644); err != nil {
t.Fatal(err)
}
}
segs := []recordSegment{{file: oldVideo, startNs: 0}}
video, jsonl, _, _, cleanup, err := prepareForMerge("没有这个-ffmpeg.exe", segs, oldJsonl, 0)
defer cleanup()
if err != nil {
t.Fatalf("单段不该报错:%v", err)
}
if video != newVideo {
t.Errorf("视频应当按保存目录里的实际位置找:\n got %s\n want %s", video, newVideo)
}
if jsonl != newJsonl {
t.Errorf("弹幕文件应当按保存目录里的实际位置找:\n got %s\n want %s", jsonl, newJsonl)
}
}
// 文件确实不在(既没在录制时的位置、也不在保存目录)时,要退回原路径而不是空串:
// 否则报错会变成「文件不存在:」,看不出到底缺的是哪个
func TestResolveAfterMoveKeepsOriginalWhenMissing(t *testing.T) {
savedConfig := config
defer func() { config = savedConfig }()
root := t.TempDir()
config.Directory = root
config.RecordSubdir = "record"
gone := filepath.Join(t.TempDir(), "这个目录是对的但文件不在.mp4")
if got := resolveAfterMove(gone); got != gone {
t.Errorf("找不到时应当退回原路径:got %q want %q", got, gone)
}
real := filepath.Join(root, "record", "在保存目录里.mp4")
if err := os.MkdirAll(filepath.Dir(real), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(real, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if got := resolveAfterMove(real); got != real {
t.Errorf("文件就在原路径时不该改:got %q want %q", got, real)
}
}
+41 -27
View File
@@ -129,7 +129,7 @@ var configMu sync.Mutex
// 设置数据 // 设置数据
type configData struct { type configData struct {
UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关 UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关
Directory string `json:"directory"` // 弹幕文件录制结束后会被移动到该文件夹 Directory string `json:"directory"` // 录播和弹幕文件保存的根目录,留空用 -record 指定的文件夹
WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI
AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播 AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播
@@ -190,25 +190,29 @@ func saveRootDir() string {
if config.Directory != "" { if config.Directory != "" {
return absOrSelf(config.Directory) return absOrSelf(config.Directory)
} }
if recordDir == nil { if recordDir != nil && *recordDir != "" {
return ""
}
return absOrSelf(*recordDir) return absOrSelf(*recordDir)
}
// 命令行参数还没解析时(比如 initialize 之前被调用)退回程序所在目录,
// 再不济退回当前工作目录:这里返回空串的话,后面拼出来的就是个相对路径,
// ffmpeg 会按自己的当前目录去写,录制文件就不知道落到哪去了
if exeDir != "" {
return absOrSelf(exeDir)
}
if wd, err := os.Getwd(); err == nil {
return wd
}
return "."
} }
// 录播和弹幕文件保存的文件夹 // 录播和弹幕文件保存的文件夹。
// 录制过程中 ffmpeg 就直接写在这里,不会再中途挪窝:以前单独有一个「录制中的目录」,
// 它只认 -record 参数不看 directory 设置,配了保存目录的人会发现文件先落在程序目录下,
// 录完才搬一次(跨盘就是整份文件再复制一遍),提前中断还会留在那儿。
func recordSaveDir() string { func recordSaveDir() string {
return filepath.Join(saveRootDir(), config.RecordSubdir) return filepath.Join(saveRootDir(), config.RecordSubdir)
} }
// 录制过程中文件直接写入的文件夹
func recordingDir() string {
if recordDir == nil {
return config.RecordSubdir
}
return filepath.Join(absOrSelf(*recordDir), config.RecordSubdir)
}
// 合并成片保存的文件夹 // 合并成片保存的文件夹
func mergeSaveDir() string { func mergeSaveDir() string {
if config.MergeOutputDir != "" { if config.MergeOutputDir != "" {
@@ -400,6 +404,20 @@ func currentAuthToken() string {
return config.AuthToken return config.AuthToken
} }
// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过。
// 启动读配置、web UI 改设置、转码排队都用这一份 —— 以前各自实现,
// 启动那份直接 os.Stat + checkErr,目录不存在时会 panic 崩掉整个进程。
func checkDirField(path, name string) string {
if path == "" {
return ""
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return name + " 必须是存在的文件夹:" + path
}
return ""
}
// 检查 config.json 里的配置 // 检查 config.json 里的配置
func checkConfig() { func checkConfig() {
for _, u := range config.UIDs { for _, u := range config.UIDs {
@@ -412,22 +430,16 @@ func checkConfig() {
lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536") lPrintErr(configFile + "里的 webPort 必须为 0(不启动 web UI)或者大于 1023 且少于 65536")
os.Exit(1) os.Exit(1)
} }
if config.Directory != "" { // 目录不在(盘没插、路径拼错)要给人话提示然后退出,不能直接 panic:
info, err := os.Stat(config.Directory) // 崩溃时文件系统风险未知,而且服务被托管时会被反复拉起来
checkErr(err) if msg := checkDirField(config.Directory, configFile+"里的directory"); msg != "" {
if !info.IsDir() { lPrintErr(msg)
lPrintErrf("%s里的directory必须是存在的文件夹:%s", configFile, config.Directory)
os.Exit(1) os.Exit(1)
} }
} if msg := checkDirField(config.MergeOutputDir, configFile+"里的mergeOutputDir"); msg != "" {
if config.MergeOutputDir != "" { lPrintErr(msg)
info, err := os.Stat(config.MergeOutputDir)
checkErr(err)
if !info.IsDir() {
lPrintErrf("%s里的mergeOutputDir必须是存在的文件夹:%s", configFile, config.MergeOutputDir)
os.Exit(1) os.Exit(1)
} }
}
if config.MergeBitrate < 0 { if config.MergeBitrate < 0 {
lPrintErrf("%s里的mergeBitrate不能小于 0", configFile) lPrintErrf("%s里的mergeBitrate不能小于 0", configFile)
os.Exit(1) os.Exit(1)
@@ -480,7 +492,7 @@ func checkConfig() {
config.FFmpegPath = "" config.FFmpegPath = ""
} }
// 确保保存文件夹存在 // 确保保存文件夹存在
for _, d := range []string{recordingDir(), recordSaveDir(), mergeSaveDir()} { for _, d := range []string{recordSaveDir(), mergeSaveDir()} {
if err := os.MkdirAll(d, 0755); err != nil { if err := os.MkdirAll(d, 0755); err != nil {
lPrintErrf("创建文件夹 %s 失败:%v", d, err) lPrintErrf("创建文件夹 %s 失败:%v", d, err)
os.Exit(1) os.Exit(1)
@@ -1119,7 +1131,9 @@ func (s *streamer) moveDanmuFiles(assFile string) {
s.moveFile(jsonlFile) s.moveFile(jsonlFile)
} }
// 移动文件 // 把送出文件到保存目录下,路径一致时什么也不做。
// 录制的产出本来就写在 recordSaveDir() 里(见 recordSaveDir 的注释),
// 这里是条保险:路径由别处给进来、或者运行中被改过保存目录时,仍以当前配置为准收拢一次。
func (s *streamer) moveFile(oldFile string) { func (s *streamer) moveFile(oldFile string) {
if oldFile == "" { if oldFile == "" {
return return
+46
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -145,3 +146,48 @@ func TestReloadConfigKeepsMergeTempFiles(t *testing.T) {
} }
} }
} }
// 保存目录填错(盘没插、路径拼错)时启动必须给一句人话再退出,
// 不能 panic 崩掉整个进程 —— 服务被托管时崩了还会被反复拉起来
func TestCheckDirFieldReportsMissingDir(t *testing.T) {
root := t.TempDir()
if msg := checkDirField("", "directory"); msg != "" {
t.Errorf("留空表示不指定,不该报错:%s", msg)
}
if msg := checkDirField(root, "directory"); msg != "" {
t.Errorf("存在的文件夹不该报错:%s", msg)
}
if msg := checkDirField(filepath.Join(root, "没有这个目录"), "directory"); !strings.Contains(msg, "必须是存在的文件夹") {
t.Errorf("目录不存在时要给出提示,实际返回 %q", msg)
}
file := filepath.Join(root, "afile")
if err := os.WriteFile(file, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
if msg := checkDirField(file, "directory"); msg == "" {
t.Error("指向文件而不是文件夹时应当报错")
}
}
// checkConfig 里曾经直接 os.Stat + checkErr:目录不在时会 panic 崩掉进程。
// 这条路径没法在测试里真跑一遍(失败时是 os.Exit(1),会把整个测试进程带走),
// 所以退一步把「只能给提示再退出」钉在源码上。
func TestCheckConfigNeverPanicsOnMissingDir(t *testing.T) {
src, err := os.ReadFile("config.go")
if err != nil {
t.Skip("读不到 config.go:" + err.Error())
}
text := string(src)
start := strings.Index(text, "func checkConfig()")
if start < 0 {
t.Fatal("config.go 里找不到 checkConfig")
}
rest := text[start:]
end := strings.Index(rest, "\n}\n")
if end < 0 {
t.Fatal("checkConfig 的函数体没有正常收尾")
}
if strings.Contains(rest[:end], "checkErr(") {
t.Error("checkConfig 里不能用 checkErr:出错会变成 panic 崩进程,改成 lPrintErr + os.Exit(1)")
}
}
+1 -1
View File
@@ -24,5 +24,5 @@ func transFilename(filename string) string {
} }
filename = filename[:cut] filename = filename[:cut]
} }
return filepath.Join(recordingDir(), filename) return filepath.Join(recordSaveDir(), filename)
} }
+86
View File
@@ -0,0 +1,86 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 录制过程中 ffmpeg 是按 transFilename 给的路径写文件的。
// 以前这条路径走的是 -record 参数而不是 directory 设置,配了保存目录的人会看到
// 文件先落在程序目录里、录完才搬一次;这里把「必须等于最终保存目录」钉死。
func TestTransFilenameSavesIntoConfiguredDir(t *testing.T) {
savedConfig, savedRecordDir := config, recordDir
defer func() { config, recordDir = savedConfig, savedRecordDir }()
root, dashDir := t.TempDir(), t.TempDir()
recordDir = &dashDir
config.RecordSubdir = "record"
// 配了 directory:不管 -record 参数是什么,都得以它为准
config.Directory = root
got := filepath.Clean(transFilename("2026-09-27 22-40-42 甲 标题.mp4"))
want := filepath.Join(root, "record", "2026-09-27 22-40-42 甲 标题.mp4")
if got != filepath.Clean(want) {
t.Errorf("配了 directory 后录制路径不对:\n got %s\n want %s", got, want)
}
if !strings.HasPrefix(got, filepath.Clean(root)) {
t.Errorf("录制路径落在了保存目录之外:%s", got)
}
// 没配 directory:回落到 -record 指定的文件夹
config.Directory = ""
got = filepath.Clean(transFilename("x.mp4"))
if filepath.Dir(got) != filepath.Clean(filepath.Join(dashDir, "record")) {
t.Errorf("directory 为空时应回落到 -record 目录:%s", got)
}
// 连 -record 都还没解析时也不能拼出相对路径,否则 ffmpeg 会写到进程的工作目录去
blank := ""
recordDir = &blank
if root := saveRootDir(); !filepath.IsAbs(root) {
t.Errorf("传入的参数都没解析时 saveRootDir 仍应是绝对路径,实际 %q", root)
}
if got := filepath.Clean(transFilename("x.mp4")); !filepath.IsAbs(got) {
t.Errorf("录制路径不能是相对路径:%q", got)
}
}
// 保存目录和录制目录现在是一个,moveFile 再挪一遍等于白复制一份(跨盘时尤其慢)
func TestMoveFileKeepsFileInSaveDir(t *testing.T) {
savedConfig, savedRecordDir := config, recordDir
defer func() { config, recordDir = savedConfig, savedRecordDir }()
root := t.TempDir()
recordDir = &root
config.Directory = root
config.RecordSubdir = "record"
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
t.Fatal(err)
}
p := filepath.Join(recordSaveDir(), "2026-09-27 22-40-42 甲 标题.mp4")
if err := os.WriteFile(p, []byte("fake"), 0644); err != nil {
t.Fatal(err)
}
s := &streamer{UID: 1}
s.moveFile(p)
if _, err := os.Stat(p); err != nil {
t.Errorf("文件已经在保存目录里了,moveFile 不该动它:%v", err)
}
// 别处给进来的路径(比如手填的绝对路径)仍要收拢到保存目录
outside := filepath.Join(t.TempDir(), "outside.mp4")
if err := os.WriteFile(outside, []byte("fake"), 0644); err != nil {
t.Fatal(err)
}
s.moveFile(outside)
if _, err := os.Stat(outside); !os.IsNotExist(err) {
t.Errorf("保存目录之外的文件应当被移动,原位置却还在")
}
if _, err := os.Stat(filepath.Join(recordSaveDir(), "outside.mp4")); err != nil {
t.Errorf("文件没有出现在保存目录里:%v", err)
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ func transFilename(filename string) string {
// 转换文件名不允许的特殊字符 // 转换文件名不允许的特殊字符
re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`) re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`)
filename = re.ReplaceAllString(filename, "-") filename = re.ReplaceAllString(filename, "-")
dir := recordingDir() dir := recordSaveDir()
outFilename := filepath.Join(dir, filename) outFilename := filepath.Join(dir, filename)
if utf8.RuneCountInString(outFilename) <= maxPathRunes { if utf8.RuneCountInString(outFilename) <= maxPathRunes {
return outFilename return outFilename
+1 -1
View File
@@ -1,6 +1,6 @@
module github.com/orzogc/acfunlive module github.com/orzogc/acfunlive
go 1.26.0 go 1.27.3
require ( require (
github.com/PuerkitoBio/goquery v1.13.0 github.com/PuerkitoBio/goquery v1.13.0
+34 -12
View File
@@ -66,17 +66,29 @@ type transcodeOptions struct {
Audio string `json:"audio"` // 音频处理,空 = 直接拷贝 Audio string `json:"audio"` // 音频处理,空 = 直接拷贝
} }
// 一个待转码的文件 // 一个待转码的文件。
// 定位源文件有两种写法,二选一:Path 填了就用它(绝对路径、相对路径都行,可以是从资源管理器
// 复制来的带引号路径),没填才按 Dir + Name 在录播/成片文件夹里找
type transcodeJobReq struct { type transcodeJobReq struct {
Path string `json:"path"`
Dir string `json:"dir"` // record / merge Dir string `json:"dir"` // record / merge
Name string `json:"name"` // 文件名 Name string `json:"name"` // 文件名
OutName string `json:"outName"` // 输出文件名,留空自动 OutName string `json:"outName"` // 输出文件名,留空自动
Options transcodeOptions `json:"options"` Options transcodeOptions `json:"options"`
} }
// 报错时给用户看的名字:按路径传进来的 Name 是空的,直接拼进消息会变成「:参数不合法」
func (j transcodeJobReq) label() string {
if p := strings.TrimSpace(j.Path); p != "" {
return filepath.Base(strings.Trim(p, `"'`))
}
return j.Name
}
// 探测到的源文件信息,页面用它显示原始参数和算预估体积 // 探测到的源文件信息,页面用它显示原始参数和算预估体积
type transcodeProbe struct { type transcodeProbe struct {
Name string `json:"name"` Name string `json:"name"`
Path string `json:"path"` // 源文件绝对路径,页面拿它当任务的身份(同名文件在不同目录不会认错)
Dir string `json:"dir"` Dir string `json:"dir"`
Size int64 `json:"size"` Size int64 `json:"size"`
Duration float64 `json:"duration"` Duration float64 `json:"duration"`
@@ -531,9 +543,19 @@ func formatByteSize(n int64) string {
return fmt.Sprintf("%.1f %s", v, units[i]) return fmt.Sprintf("%.1f %s", v, units[i])
} }
// 转码任务的输入必须来自文件管理里的两个文件夹,和手动合并一致 // 转码任务的输入:填了路径就按路径找(任意文件夹都行),否则按文件名在录播/成片文件夹里找。
func resolveTranscodeInput(dir, name string) (string, error) { // 两条路都要过一遍格式白名单 —— 路径这条能指向磁盘上任何文件,不挡的话
full, err := resolveFile(dir, name) // 用户手滑把 .jsonl 弹幕丢进来,要等 ffmpeg 跑起来才报一句看不懂的错
func resolveTranscodeJob(j transcodeJobReq) (string, error) {
var (
full string
err error
)
if p := strings.TrimSpace(j.Path); p != "" {
full, err = resolveMergeInput(p)
} else {
full, err = resolveFile(j.Dir, j.Name)
}
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -650,7 +672,7 @@ func runTranscode(job transcodeJobReq, outDir string, idx, total int) (string, e
if ffmpegFile == "" { if ffmpegFile == "" {
return "", errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径") return "", errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
} }
in, err := resolveTranscodeInput(job.Dir, job.Name) in, err := resolveTranscodeJob(job)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -803,7 +825,7 @@ func transcodeProbeHandler(w http.ResponseWriter, r *http.Request) {
out := make([]transcodeProbe, 0, len(req.Files)) out := make([]transcodeProbe, 0, len(req.Files))
for _, f := range req.Files { for _, f := range req.Files {
full, err := resolveTranscodeInput(f.Dir, f.Name) full, err := resolveTranscodeJob(f)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -815,7 +837,7 @@ func transcodeProbeHandler(w http.ResponseWriter, r *http.Request) {
} }
m := probeMediaCached(ffmpegFile, full) m := probeMediaCached(ffmpegFile, full)
out = append(out, transcodeProbe{ out = append(out, transcodeProbe{
Name: filepath.Base(full), Dir: f.Dir, Size: st.Size(), Name: filepath.Base(full), Path: full, Dir: f.Dir, Size: st.Size(),
Duration: m.duration, Width: m.width, Height: m.height, Fps: m.fps, Duration: m.duration, Width: m.width, Height: m.height, Fps: m.fps,
// 视频码率在这里就扣掉音轨:后面预估体积还要单独加一次音频, // 视频码率在这里就扣掉音轨:后面预估体积还要单独加一次音频,
// 直接用容器总码率会把音轨算成两份 // 直接用容器总码率会把音轨算成两份
@@ -852,10 +874,10 @@ func transcodeEstimateHandler(w http.ResponseWriter, r *http.Request) {
out := make([]transcodeEstimate, 0, len(req.Jobs)) out := make([]transcodeEstimate, 0, len(req.Jobs))
for _, j := range req.Jobs { for _, j := range req.Jobs {
if msg := j.Options.validate(ffmpegFile); msg != "" { if msg := j.Options.validate(ffmpegFile); msg != "" {
http.Error(w, j.Name+":"+msg, http.StatusBadRequest) http.Error(w, j.label()+":"+msg, http.StatusBadRequest)
return return
} }
full, err := resolveTranscodeInput(j.Dir, j.Name) full, err := resolveTranscodeJob(j)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -907,10 +929,10 @@ func transcodeHandler(w http.ResponseWriter, r *http.Request) {
// 探测走缓存,刚才「加入列表」和「估算体积」时已经探过,这里不会再多起 ffmpeg // 探测走缓存,刚才「加入列表」和「估算体积」时已经探过,这里不会再多起 ffmpeg
for _, j := range req.Jobs { for _, j := range req.Jobs {
if msg := j.Options.validate(ffmpegFile); msg != "" { if msg := j.Options.validate(ffmpegFile); msg != "" {
http.Error(w, j.Name+":"+msg, http.StatusBadRequest) http.Error(w, j.label()+":"+msg, http.StatusBadRequest)
return return
} }
full, err := resolveTranscodeInput(j.Dir, j.Name) full, err := resolveTranscodeJob(j)
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
@@ -934,7 +956,7 @@ func transcodeHandler(w http.ResponseWriter, r *http.Request) {
if outDir == "" { if outDir == "" {
outDir = mergeSaveDir() outDir = mergeSaveDir()
} else { } else {
if msg := checkWebDir(outDir, "输出目录"); msg != "" { if msg := checkDirField(outDir, "输出目录"); msg != "" {
http.Error(w, msg, http.StatusBadRequest) http.Error(w, msg, http.StatusBadRequest)
return return
} }
+92
View File
@@ -1,10 +1,14 @@
package main package main
import ( import (
"encoding/json"
"math" "math"
"net/http"
"net/http/httptest"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"testing" "testing"
) )
@@ -477,6 +481,94 @@ func has(args []string, v string) bool {
return false return false
} }
// ==================== 源文件定位 ====================
// 填了路径就按路径找,录播/成片文件夹之外的文件也要能转;没填才回落到 dir + name
func TestResolveTranscodeJobByPath(t *testing.T) {
setupMergeTest(t)
outside := t.TempDir()
video := filepath.Join(outside, "别处的录播.mp4")
writeTestFile(t, video, "x")
// 绝对路径要认,从资源管理器复制来的带引号路径也要认
for _, in := range []string{video, `"` + video + `"`} {
got, err := resolveTranscodeJob(transcodeJobReq{Path: in})
if err != nil {
t.Fatalf("%q 应当能解析:%v", in, err)
}
if normalizePath(got) != normalizePath(video) {
t.Errorf("%q 解析成了 %q,想要 %q", in, got, video)
}
}
// 不填路径时还是按录播目录 + 文件名找,老写法不能坏
rec := filepath.Join(recordSaveDir(), "录播里的.mp4")
writeTestFile(t, rec, "x")
got, err := resolveTranscodeJob(transcodeJobReq{Dir: "record", Name: "录播里的.mp4"})
if err != nil {
t.Fatalf("dir + name 应当能解析:%v", err)
}
if normalizePath(got) != normalizePath(rec) {
t.Errorf("dir + name 解析成了 %q,想要 %q", got, rec)
}
}
// 路径这条能指向磁盘上任何文件,格式白名单必须照样过一遍
func TestResolveTranscodeJobRejectsBadPath(t *testing.T) {
setupMergeTest(t)
outside := t.TempDir()
danmu := filepath.Join(outside, "某场.jsonl")
writeTestFile(t, danmu, "{}")
sub := filepath.Join(outside, "子文件夹")
if err := os.MkdirAll(sub, 0755); err != nil {
t.Fatal(err)
}
bad := map[string]transcodeJobReq{
"不是视频后缀": {Path: danmu},
"指向文件夹": {Path: sub},
"路径不存在": {Path: filepath.Join(outside, "没有这个文件.mp4")},
"路径和名字都没填": {},
}
for name, j := range bad {
if _, err := resolveTranscodeJob(j); err == nil {
t.Errorf("%s:应当被挡住,实际通过了", name)
}
}
}
// 探测接口要认 {path} —— 这是「转码任意文件」的实际入口,只测 resolve 不足以证明接线对了。
// 这里故意用一个不是视频的文件:探测拿不到流信息,但接口仍要 200 并把路径原样回显
func TestTranscodeProbeHandlerAcceptsPath(t *testing.T) {
setupMergeTest(t)
outside := t.TempDir()
video := filepath.Join(outside, "别处.mp4")
writeTestFile(t, video, "not a real video")
body := `{"files":[{"path":` + strconv.Quote(video) + `}]}`
req := httptest.NewRequest("POST", "/api/transcode/probe", strings.NewReader(body))
rec := httptest.NewRecorder()
transcodeProbeHandler(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("按路径探测应当 200,实际 %d:%s", rec.Code, rec.Body.String())
}
var resp struct {
Files []struct {
Name string `json:"name"`
Path string `json:"path"`
} `json:"files"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if len(resp.Files) != 1 || resp.Files[0].Name != "别处.mp4" {
t.Fatalf("探测结果不对:%+v", resp.Files)
}
if normalizePath(resp.Files[0].Path) != normalizePath(video) {
t.Errorf("回显的 path 是 %q,想要 %q", resp.Files[0].Path, video)
}
}
func indexOf(args []string, v string) int { func indexOf(args []string, v string) int {
for i, a := range args { for i, a := range args {
if a == v { if a == v {
+77 -39
View File
@@ -439,13 +439,24 @@ const webuiHTML = `<!DOCTYPE html>
<!-- 转码 --> <!-- 转码 -->
<section class="pane" data-pane="trc"> <section class="pane" data-pane="trc">
<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="bar" style="margin-top:0"> <div class="grid">
<select id="tcFilePick" multiple size="6" style="flex:1 1 260px;min-width:0"></select> <div class="f">
<div style="flex:1 1 160px;display:flex;flex-direction:column;gap:8px"> <label>从文件列表挑(可多选,按 Ctrl 点选)</label>
<select id="tcFilePick" multiple size="6"></select>
<div class="bar">
<button class="ghost" onclick="addTranscodeFiles()">加入列表</button> <button class="ghost" onclick="addTranscodeFiles()">加入列表</button>
<button class="ghost" onclick="clearTranscodeFiles()">清空列表</button> <button class="ghost" onclick="clearTranscodeFiles()">清空列表</button>
<span class="hint" style="margin:0">按住 Ctrl 点可以一次选多个</span> </div>
</div>
<div class="f">
<label>自己填路径(一行一个;在录播/成片文件夹里的可以只写文件名)</label>
<textarea id="tcPaths" rows="6" spellcheck="false" placeholder="一行一个路径,例如 D:\别处\某场录播.mp4"></textarea>
<div class="bar">
<button class="ghost" onclick="browseTranscodePaths()">浏览…</button>
<button class="ghost" onclick="addTranscodePathsFromBox()">加入列表</button>
<button class="ghost" onclick="clearTranscodePathBox()">清空输入框</button>
</div>
</div> </div>
</div> </div>
<div class="hint">加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。</div> <div class="hint">加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。</div>
@@ -1030,6 +1041,7 @@ async function loadFiles(b){
var d = await r.json(); var d = await r.json();
fileList = d.files || []; fileList = d.files || [];
recDir = d.recordDir || ""; recDir = d.recordDir || "";
mergeDir = d.mergeDir || "";
fillMergePickers(); fillMergePickers();
fillTranscodePick(); fillTranscodePick();
document.getElementById("diskInfo").textContent = document.getElementById("diskInfo").textContent =
@@ -1078,11 +1090,13 @@ var pickMulti = false; // 多选模式(视频框这种一行一个、可以选
var pickCb = null; // 选完后交给它的回调,不为空时不再往输入框里填 var pickCb = null; // 选完后交给它的回调,不为空时不再往输入框里填
var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒 var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒
var recDir = ""; // 录播目录,挑弹幕和视频时从它开始 var recDir = ""; // 录播目录,挑弹幕和视频时从它开始
var mergeDir = ""; // 成片目录,转码挑文件时从它开始(比空的录播目录更有东西可挑)
var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次 var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次
var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层 var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层
// cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用) // cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用);
function openPick(id, kind, multi, cb){ // startAt 不为空时从它开始(不给就按框里已填的内容推)
function openPick(id, kind, multi, cb, startAt){
var el = id ? document.getElementById(id) : null; var el = id ? document.getElementById(id) : null;
pickField = id; pickField = id;
pickKind = kind === "file" ? "file" : "dir"; pickKind = kind === "file" ? "file" : "dir";
@@ -1098,9 +1112,9 @@ function openPick(id, kind, multi, cb){
document.getElementById("pick").classList.toggle("multi", pickMulti); document.getElementById("pick").classList.toggle("multi", pickMulti);
document.getElementById("pick").classList.add("on"); document.getElementById("pick").classList.add("on");
// 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录 // 多行框(视频)取第一行当起点,整段拿去当路径会解析出一个乱七八糟的目录
var start = el ? el.value.split("\n")[0].trim() : ""; var start = startAt || (el ? el.value.split("\n")[0].trim() : "");
// 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始 // 框里是文件名而不是路径时(弹幕名、视频名这种)不算路径:这两个框从录播目录开始,其它从程序所在目录开始
if(start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || !id) ? recDir : ""; if(!startAt && start.indexOf("/") < 0 && start.indexOf("\\") < 0) start = (id === "mrgDanmu" || !id) ? recDir : "";
pickGo(start); pickGo(start);
} }
function closePick(){ document.getElementById("pick").classList.remove("on"); } function closePick(){ document.getElementById("pick").classList.remove("on"); }
@@ -1573,37 +1587,76 @@ function defaultTranscodeOpts(){
function defaultTranscodeOut(p){ function defaultTranscodeOut(p){
return p.name.replace(/\.[^.]+$/, "") + "_转码.mp4"; return p.name.replace(/\.[^.]+$/, "") + "_转码.mp4";
} }
// 一个转码任务的身份:有绝对路径就用它(同名文件在不同文件夹里不会认成一个),没有才退回 dir+name
function tcJobKey(x){
if(!x) return "";
if(x.path) return String(x.path).toLowerCase();
return ((x.dir ? x.dir + "/" : "") + (x.name || "")).toLowerCase();
}
// 从「文件列表」下拉框里把勾中的加进待转码列表
async function addTranscodeFiles(){ async function addTranscodeFiles(){
var sel = document.getElementById("tcFilePick"); var sel = document.getElementById("tcFilePick");
var picks = []; var cands = [];
Array.prototype.forEach.call(sel.options, function(o){ Array.prototype.forEach.call(sel.options, function(o){
if(o.selected){ if(!o.selected) return;
var f = tcVideos[parseInt(o.value, 10)]; var f = tcVideos[parseInt(o.value, 10)];
if(f && !tcJobs.some(function(j){ return j.dir === f.dir && j.name === f.name; })) picks.push(f); if(f) cands.push({dir: f.dir, name: f.name});
}
}); });
if(!picks.length){ showMsg(false, tcJobs.length ? "选中的文件已经在列表里了" : "请先选择要转码的文件"); return; } if(!cands.length){ showMsg(false, tcJobs.length ? "请先在左侧列表里勾选文件" : "请先选择要转码的文件"); return; }
await addTranscodeCands(cands);
}
// 把输入框里的路径加进待转码列表(一行一个,空行忽略)
function addTranscodePathsFromBox(){
var lines = transcodePathLines();
if(!lines.length){ showMsg(false, "请先在输入框里填文件路径,或者点「浏览…」挑"); return; }
return addTranscodeCands(lines.map(function(p){ return {path: p}; }));
}
function transcodePathLines(){
var el = document.getElementById("tcPaths");
if(!el) return [];
return el.value.split("\n").map(function(s){ return s.trim(); }).filter(function(s){ return s; });
}
function clearTranscodePathBox(){ document.getElementById("tcPaths").value = ""; }
// 浏览按钮:走和手动合并一样的目录选择器,可多选、可跨文件夹跨盘攒;
// 选完只往输入框里追加,不清掉已经填好的
function browseTranscodePaths(){
openPick(null, "file", true, function(paths){
var el = document.getElementById("tcPaths");
var lines = transcodePathLines();
(paths || []).forEach(function(p){
if(p && lines.indexOf(p) < 0) lines.push(p);
});
el.value = lines.join("\n");
}, mergeDir);
}
// 加入待转码列表的统一入口:cands 每项要么 {path}(浏览/手填来的),要么 {dir,name}(文件列表挑的)
async function addTranscodeCands(cands){
if(!cands || !cands.length) return;
var r = null; var r = null;
try{ try{
r = await api("/api/transcode/probe", {method:"POST", headers:{"Content-Type":"application/json"}, r = await api("/api/transcode/probe", {method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({files: picks.map(function(f){ return {dir: f.dir, name: f.name}; })})}); body: JSON.stringify({files: cands})});
}catch(e){} }catch(e){}
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; }
if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; } if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; }
var d = null; var d = null;
try{ d = await r.json(); }catch(e){ showMsg(false, "读文件信息失败"); return; } try{ d = await r.json(); }catch(e){ showMsg(false, "读文件信息失败"); return; }
if(d && d.encoders && d.encoders.length) tcEncoders = d.encoders; if(d && d.encoders && d.encoders.length) tcEncoders = d.encoders;
var skipped = []; var skipped = [], dup = 0, added = 0;
(d.files || []).forEach(function(p){ (d.files || []).forEach(function(p){
// 只有音轨(或只有封面图)的文件排进去也是白排,ffmpeg 会直接报没视频流 // 只有音轨(或只有封面图)的文件排进去也是白排,ffmpeg 会直接报没视频流
if(!p.hasVideo){ skipped.push(p.name); return; } if(!p.hasVideo){ skipped.push(p.name); return; }
tcJobs.push({dir: p.dir, name: p.name, size: p.size, dur: p.duration, w: p.width, h: p.height, var key = tcJobKey(p);
fps: p.fps, vkbps: p.videoKbps, akbps: p.audioKbps, audio: !!p.hasAudio, codec: p.videoCodec, if(tcJobs.some(function(j){ return tcJobKey(j) === key; })){ dup++; return; }
out: defaultTranscodeOut(p), opts: defaultTranscodeOpts(), est: null}); tcJobs.push({path: p.path || "", dir: p.dir || "", name: p.name, size: p.size, dur: p.duration,
w: p.width, h: p.height, fps: p.fps, vkbps: p.videoKbps, akbps: p.audioKbps,
audio: !!p.hasAudio, codec: p.videoCodec, out: defaultTranscodeOut(p), opts: defaultTranscodeOpts(), est: null});
added++;
}); });
renderTranscodeRows(); renderTranscodeRows();
estimateTranscode(); estimateTranscode();
if(skipped.length) showMsg(false, "这些文件里没有视频轨,已跳过:" + skipped.join("、")); if(skipped.length) showMsg(false, "这些文件里没有视频轨,已跳过:" + skipped.join("、"));
else if(!added && dup) showMsg(false, "选中的文件已经在列表里了");
} }
function clearTranscodeFiles(){ function clearTranscodeFiles(){
if(tcBusy) return; if(tcBusy) return;
@@ -1655,7 +1708,7 @@ function renderTranscodeRows(){
return h === 0 || !job.h || h < job.h; return h === 0 || !job.h || h < job.h;
}); });
return '<div class="trow" data-i="' + i + '">' return '<div class="trow" data-i="' + i + '">'
+ '<div class="th"><span class="tn">' + esc(job.name) + '</span>' + '<div class="th"><span class="tn" title="' + esc(job.path || job.name) + '">' + esc(job.name) + '</span>'
+ '<span class="tm">' + esc(meta) + '</span>' + '<span class="tm">' + esc(meta) + '</span>'
+ '<button class="mini" onclick="removeTranscodeFile(' + i + ')">移除</button></div>' + '<button class="mini" onclick="removeTranscodeFile(' + i + ')">移除</button></div>'
+ '<div class="tg">' + '<div class="tg">'
@@ -1728,7 +1781,7 @@ function scheduleEstimate(){
async function estimateTranscode(){ async function estimateTranscode(){
if(!tcJobs.length){ updateTranscodeTotal(); return; } if(!tcJobs.length){ updateTranscodeTotal(); return; }
var seq = ++tcEstSeq; var seq = ++tcEstSeq;
var body = {jobs: tcJobs.map(function(j){ return {dir: j.dir, name: j.name, options: j.opts}; })}; var body = {jobs: tcJobs.map(function(j){ return {path: j.path || "", dir: j.dir, name: j.name, options: j.opts}; })};
var r = null; var r = null;
try{ try{
r = await api("/api/transcode/estimate", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)}); r = await api("/api/transcode/estimate", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
@@ -1819,7 +1872,7 @@ async function startTranscode(){
var n = tcJobs.length; var n = tcJobs.length;
if(!confirm(n > 1 ? ("开始转码这 " + n + " 个文件?会按列表顺序一个一个来。") : "开始转码?")) return; if(!confirm(n > 1 ? ("开始转码这 " + n + " 个文件?会按列表顺序一个一个来。") : "开始转码?")) return;
var body = {outDir: document.getElementById("tcOutDir").value.trim(), var body = {outDir: document.getElementById("tcOutDir").value.trim(),
jobs: tcJobs.map(function(j){ return {dir: j.dir, name: j.name, outName: j.out, options: j.opts}; })}; jobs: tcJobs.map(function(j){ return {path: j.path || "", dir: j.dir, name: j.name, outName: j.out, options: j.opts}; })};
var b = document.getElementById("tcGo"); var b = document.getElementById("tcGo");
b.disabled = true; b.disabled = true;
var r = null; var r = null;
@@ -2378,18 +2431,6 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
checkErr(err) checkErr(err)
} }
// 检查配置里的文件夹字段,返回错误信息,空字符串表示通过
func checkWebDir(path, name string) string {
if path == "" {
return ""
}
info, err := os.Stat(path)
if err != nil || !info.IsDir() {
return name + " 必须是存在的文件夹:" + path
}
return ""
}
// 修改设置(保存目录和自动合并相关) // 修改设置(保存目录和自动合并相关)
func webConfigHandler(w http.ResponseWriter, r *http.Request) { func webConfigHandler(w http.ResponseWriter, r *http.Request) {
var req struct { var req struct {
@@ -2428,11 +2469,11 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest) http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return return
} }
if msg := checkWebDir(req.Directory, "directory"); msg != "" { if msg := checkDirField(req.Directory, "directory"); msg != "" {
http.Error(w, msg, http.StatusBadRequest) http.Error(w, msg, http.StatusBadRequest)
return return
} }
if msg := checkWebDir(req.MergeOutputDir, "mergeOutputDir"); msg != "" { if msg := checkDirField(req.MergeOutputDir, "mergeOutputDir"); msg != "" {
http.Error(w, msg, http.StatusBadRequest) http.Error(w, msg, http.StatusBadRequest)
return return
} }
@@ -2541,9 +2582,6 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight) config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight)
config.MeowImgURL = meowImgURL config.MeowImgURL = meowImgURL
// 修改了子文件夹名后确保新文件夹存在 // 修改了子文件夹名后确保新文件夹存在
if err := os.MkdirAll(recordingDir(), 0755); err != nil {
lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err)
}
if err := os.MkdirAll(recordSaveDir(), 0755); err != nil { if err := os.MkdirAll(recordSaveDir(), 0755); err != nil {
lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err) lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err)
} }
+1
View File
@@ -350,6 +350,7 @@ func TestWebuiSettingFieldsMatchInputs(t *testing.T) {
"pickPath": "路径选择弹窗的内部状态", "pickPath": "路径选择弹窗的内部状态",
"mrgVideoPick": "手动合并:视频列表,随合并请求提交", "mrgVideoPick": "手动合并:视频列表,随合并请求提交",
"tcFilePick": "转码:待选文件列表,随转码请求提交", "tcFilePick": "转码:待选文件列表,随转码请求提交",
"tcPaths": "转码:手填的文件路径列表,随转码请求提交",
"tcOutDir": "转码:输出目录,随转码请求提交", "tcOutDir": "转码:输出目录,随转码请求提交",
} }
for id := range ids { for id := range ids {