Files
tamakyi e35a4f7cef 合并先在本地做完再落到成片目录;头像支持 gif 和静态 webp
起因是核查合并落盘和头像处理时发现的两处问题:

1) 成片落盘:ffmpeg 原来是直接写目标目录(automerge.go 里那句注释「不再事后再
   移动一次」)。成片目录可能是网络盘或外接盘,写到一半失败会在那里留下一个
   看起来正常的半成品。现在改成先本地合成、完成后再落盘:
   - 输出先写到弹幕文件所在目录(本地)的 acfunlive_chunk_merging_*
   - 写完后由 placeOutput 落盘:同盘直接 os.Rename(瞬时),跨盘退回拷贝;
     拷贝中途失败留下的是 *.acfunlive.part,一眼看得出不是成品
   - 成功或失败都会清掉本地临时文件,失败时两个目录都不留东西
   - 临时文件沿用 chunkTempPrefix,不再出现在文件管理的列表里(listFiles 过滤)
   - 启动时清理上次残留;删不掉时提示一句 —— 强杀程序不会连带杀掉 ffmpeg
     子进程,它可能还在写这个文件,这种情况下次启动会再清一次

2) 头像:downloadAvatar 只注册了 jpeg 和 png 解码器,AcFun 上的 gif 头像必然
   解码失败,静态 webp 同样。加上 _ "image/gif"(多帧取第一帧)和
   _ "golang.org/x/image/webp"(x/image 本来就是直接依赖,零新增模块)。
   动画 webp 两条路都解不开(x/image 报 invalid format,ffmpeg 5.0 也不行),
   保持只烧文字。解码失败时错误信息带上 Content-Type,方便排查是哪张图。

在隔离实例上用真 ffmpeg 跑了真合并验证:gif 头像和 webp 头像都进了缓存并烧进
画面(抽帧确认圆形头像);日志里 ffmpeg 的输出是 record 下的临时文件,完成后
才出现在 merged;坏视频合成失败后成片目录没有半成品、录播目录零残留;合并
中途强杀留下的临时文件在下次启动时被清掉。

新增 output_test.go(7 个)和 avatar_test.go(3 个);go test ./...、gofmt、
go vet 干净。
2026-09-25 17:34:31 +08:00

249 lines
6.7 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// web UI 的文件管理和磁盘空间
package main
import (
"encoding/json"
"errors"
"net/http"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
)
// 文件管理里的一个文件
type fileEntry struct {
Name string `json:"name"` // 文件名
Dir string `json:"dir"` // record 录播,merge 成片
Size int64 `json:"size"` // 字节
ModTime int64 `json:"modTime"` // 修改时间,Unix 秒
InUse bool `json:"inUse"` // 正在写入(录制分段或合并成片),不允许删除
}
// 正在被写入的文件集合,避免用户在 web UI 里删掉还在录制的文件
var activeFiles struct {
sync.Mutex
m map[string]struct{}
}
// 路径比较用的统一形式:先转绝对路径,Windows 下再统一大小写
// 录制侧拼出来的路径可能是相对的(-record .),文件管理接口拼的是绝对路径,
// 不先归一的话「正在写入」的判断会对不上
func normalizePath(path string) string {
if abs, err := filepath.Abs(path); err == nil {
path = abs
}
path = filepath.Clean(path)
if runtime.GOOS == "windows" {
path = strings.ToLower(path)
}
return path
}
// 标记文件开始/结束写入
func markFileActive(path string, active bool) {
if path == "" {
return
}
key := normalizePath(path)
activeFiles.Lock()
defer activeFiles.Unlock()
if activeFiles.m == nil {
activeFiles.m = make(map[string]struct{})
}
if active {
activeFiles.m[key] = struct{}{}
} else {
delete(activeFiles.m, key)
}
}
// 文件是否正在被写入
func isFileActive(path string) bool {
key := normalizePath(path)
activeFiles.Lock()
defer activeFiles.Unlock()
_, ok := activeFiles.m[key]
return ok
}
// 文件管理里 dir 参数对应的文件夹
func fileDir(kind string) string {
if kind == "merge" {
return mergeSaveDir()
}
return recordSaveDir()
}
// 只允许文件名本身,防止用相对路径跳出录音文件夹
func resolveFile(kind, name string) (string, error) {
if name == "" {
return "", errors.New("缺少文件名")
}
// 不管哪个平台都拒绝路径分隔符,Windows 下 Base 会认反斜杠,Linux 下不会
if name == "." || name == ".." || name != filepath.Base(filepath.FromSlash(name)) ||
strings.ContainsAny(name, `/\`) {
return "", errors.New("文件名不合法")
}
dir, err := filepath.Abs(fileDir(kind))
if err != nil {
return "", err
}
full := filepath.Join(dir, name)
rel, err := filepath.Rel(dir, full)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", errors.New("文件名不合法")
}
return full, nil
}
// 列出文件夹里的文件(不含子文件夹)
func listFiles(dir string) []fileEntry {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
out := make([]fileEntry, 0, len(entries))
for _, e := range entries {
if e.IsDir() {
continue
}
// 合成过程中的临时文件(拼接、分块、本地临时成片)不上列表
if strings.HasPrefix(e.Name(), chunkTempPrefix) || strings.HasSuffix(e.Name(), partSuffix) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, fileEntry{
Name: e.Name(),
Size: info.Size(),
ModTime: info.ModTime().Unix(),
InUse: isFileActive(filepath.Join(dir, e.Name())),
})
}
return out
}
// 浏览器能直接播放的格式
func playableFile(name string) bool {
switch strings.ToLower(filepath.Ext(name)) {
case ".mp4", ".m4v", ".webm", ".ogv":
return true
}
return false
}
// 显示用的绝对路径,取不到就返回原值
func absPath(path string) string {
if abs, err := filepath.Abs(path); err == nil {
return abs
}
return path
}
// 列出录播和成片文件夹里的文件、目录位置和磁盘剩余空间
func filesHandler(w http.ResponseWriter, r *http.Request) {
recordDir, mergeDir := recordSaveDir(), mergeSaveDir()
files := make([]fileEntry, 0, 64)
for _, f := range listFiles(recordDir) {
f.Dir = "record"
files = append(files, f)
}
if normalizePath(mergeDir) != normalizePath(recordDir) {
for _, f := range listFiles(mergeDir) {
f.Dir = "merge"
files = append(files, f)
}
}
// 最近修改的排前面
sort.Slice(files, func(i, j int) bool {
if files[i].ModTime != files[j].ModTime {
return files[i].ModTime > files[j].ModTime
}
return files[i].Name < files[j].Name
})
free, total, err := diskSpace(saveRootDir())
if err != nil {
lPrintWarnf("获取磁盘剩余空间失败:%v", err)
}
resp := struct {
RecordDir string `json:"recordDir"`
MergeDir string `json:"mergeDir"`
Free uint64 `json:"free"`
Total uint64 `json:"total"`
Files []fileEntry `json:"files"`
}{absPath(recordDir), absPath(mergeDir), free, total, files}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
lPrintErrf("输出文件列表失败:%v", err)
}
}
// 下载文件,?inline=1 时让浏览器直接播放(只对浏览器支持的格式生效)
func fileDownloadHandler(w http.ResponseWriter, r *http.Request) {
kind := r.URL.Query().Get("dir")
name := r.URL.Query().Get("name")
full, err := resolveFile(kind, name)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
info, err := os.Stat(full)
if err != nil || info.IsDir() {
http.Error(w, "文件不存在", http.StatusNotFound)
return
}
if r.URL.Query().Get("inline") == "1" && playableFile(name) {
// ServeFile 会用文件扩展名推测 Content-Type,这里不额外设置
} else {
// 中文文件名要用 RFC 5987 的写法,否则部分浏览器会乱码
w.Header().Set("Content-Disposition",
"attachment; filename*=UTF-8''"+url.PathEscape(filepath.Base(full)))
}
http.ServeFile(w, r, full)
}
// 删除文件
func fileRemoveHandler(w http.ResponseWriter, r *http.Request) {
var req struct {
Dir string `json:"dir"`
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
full, err := resolveFile(req.Dir, req.Name)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if isFileActive(full) {
http.Error(w, "这个文件正在录制或合并中,不能删除", http.StatusConflict)
return
}
info, err := os.Stat(full)
if err != nil || info.IsDir() {
http.Error(w, "文件不存在", http.StatusNotFound)
return
}
if err := os.Remove(full); err != nil {
http.Error(w, "删除失败:"+err.Error(), http.StatusInternalServerError)
return
}
lPrintf("通过 web UI 删除了文件 %s(%d 字节)", full, info.Size())
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
}