This commit is contained in:
tamakyi
2026-09-25 13:16:54 +08:00
commit ed4e47e305
32 changed files with 9214 additions and 0 deletions
+244
View File
@@ -0,0 +1,244 @@
// 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
}
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"))
}