From 7ab80ec4290d6dcacc493e9bc5bbe37f377aaabd Mon Sep 17 00:00:00 2001 From: tamakyi Date: Mon, 28 Sep 2026 02:03:39 +0800 Subject: [PATCH] =?UTF-8?q?=E5=BD=95=E5=88=B6=E8=BF=87=E7=A8=8B=E7=9B=B4?= =?UTF-8?q?=E6=8E=A5=E5=86=99=E8=BF=9B=E9=85=8D=E7=BD=AE=E9=87=8C=E7=9A=84?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E7=9B=AE=E5=BD=95=EF=BC=8C=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=90=BD=E5=88=B0=E7=A8=8B=E5=BA=8F=E7=9B=AE=E5=BD=95=E4=B8=8B?= =?UTF-8?q?=E7=BB=95=E4=B8=80=E5=9C=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么: 原来存在两个语义几乎相同的目录函数: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 下,程序目录里一个都没有。 --- README.md | 4 +-- config.go | 36 +++++++++++-------- filename_other.go | 2 +- filename_test.go | 86 +++++++++++++++++++++++++++++++++++++++++++++ filename_windows.go | 2 +- webui.go | 3 -- 6 files changed, 111 insertions(+), 22 deletions(-) create mode 100644 filename_test.go diff --git a/README.md b/README.md index f45e4a3..a6779b0 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "qualities": [] // 开播时读到的码率档位列表,缓存下来让没开播时也能显示对应关系 } ], - "directory": "", // 录制文件保存的文件夹,留空是程序运行时的 -record 文件夹 + "directory": "", // 录播和弹幕文件的保存根目录,录制时就直接写在这里;留空用运行时的 -record 文件夹 "webPort": 51880, // web UI 的端口,为 0 时不启动 web UI "autoMerge": false, // 直播结束后自动把弹幕层合并进录播 @@ -127,7 +127,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe 档位表要开播后读直播源列表才知道(写在`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`)不会出现在文件管理的列表里,程序下次启动会自动清掉。 diff --git a/config.go b/config.go index 5438bf9..12cbb81 100644 --- a/config.go +++ b/config.go @@ -129,7 +129,7 @@ var configMu sync.Mutex // 设置数据 type configData struct { UIDs uidList `json:"uids"` // 要录制的主播 uid 列表和各自的开关 - Directory string `json:"directory"` // 弹幕文件录制结束后会被移动到该文件夹 + Directory string `json:"directory"` // 录播和弹幕文件保存的根目录,留空用 -record 指定的文件夹 WebPort int `json:"webPort"` // web UI 的本地端口,为 0 时不启动 web UI AutoMerge bool `json:"autoMerge"` // 直播结束后自动把弹幕层合并进录播 @@ -190,25 +190,29 @@ func saveRootDir() string { if config.Directory != "" { return absOrSelf(config.Directory) } - if recordDir == nil { - return "" + if recordDir != nil && *recordDir != "" { + 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 { 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 { if config.MergeOutputDir != "" { @@ -480,7 +484,7 @@ func checkConfig() { config.FFmpegPath = "" } // 确保保存文件夹存在 - for _, d := range []string{recordingDir(), recordSaveDir(), mergeSaveDir()} { + for _, d := range []string{recordSaveDir(), mergeSaveDir()} { if err := os.MkdirAll(d, 0755); err != nil { lPrintErrf("创建文件夹 %s 失败:%v", d, err) os.Exit(1) @@ -1119,7 +1123,9 @@ func (s *streamer) moveDanmuFiles(assFile string) { s.moveFile(jsonlFile) } -// 移动文件 +// 把送出文件到保存目录下,路径一致时什么也不做。 +// 录制的产出本来就写在 recordSaveDir() 里(见 recordSaveDir 的注释), +// 这里是条保险:路径由别处给进来、或者运行中被改过保存目录时,仍以当前配置为准收拢一次。 func (s *streamer) moveFile(oldFile string) { if oldFile == "" { return diff --git a/filename_other.go b/filename_other.go index dfad9e6..19b28de 100644 --- a/filename_other.go +++ b/filename_other.go @@ -24,5 +24,5 @@ func transFilename(filename string) string { } filename = filename[:cut] } - return filepath.Join(recordingDir(), filename) + return filepath.Join(recordSaveDir(), filename) } diff --git a/filename_test.go b/filename_test.go new file mode 100644 index 0000000..0a039e2 --- /dev/null +++ b/filename_test.go @@ -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) + } +} diff --git a/filename_windows.go b/filename_windows.go index f107ca9..383c768 100644 --- a/filename_windows.go +++ b/filename_windows.go @@ -16,7 +16,7 @@ func transFilename(filename string) string { // 转换文件名不允许的特殊字符 re := regexp.MustCompile(`[<>:"/\\|?*\r\n]`) filename = re.ReplaceAllString(filename, "-") - dir := recordingDir() + dir := recordSaveDir() outFilename := filepath.Join(dir, filename) if utf8.RuneCountInString(outFilename) <= maxPathRunes { return outFilename diff --git a/webui.go b/webui.go index 887fbd1..2caa097 100644 --- a/webui.go +++ b/webui.go @@ -2594,9 +2594,6 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) { config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight) config.MeowImgURL = meowImgURL // 修改了子文件夹名后确保新文件夹存在 - if err := os.MkdirAll(recordingDir(), 0755); err != nil { - lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err) - } if err := os.MkdirAll(recordSaveDir(), 0755); err != nil { lPrintWarnf("创建文件夹 %s 失败:%v", recordSaveDir(), err) }