From 3c17503a21357eadd91a82f3b5f270f87997a2f5 Mon Sep 17 00:00:00 2001 From: tamakyi Date: Sun, 27 Sep 2026 11:10:00 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BD=AC=E7=A0=81=E7=9A=84=E6=BA=90=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E4=B8=8D=E5=86=8D=E9=99=90=E6=AD=BB=E5=9C=A8=E5=BD=95?= =?UTF-8?q?=E6=92=AD/=E6=88=90=E7=89=87=E7=9B=AE=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E5=8F=AF=E4=BB=A5=E5=A1=AB=E4=BB=BB=E6=84=8F=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么 转码页的多选框只列 /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 发、重复加入不重复、浏览弹窗打开/追加不 覆盖/关闭/清空)。 --- README.md | 13 ++++-- transcode.go | 44 +++++++++++++++----- transcode_test.go | 92 +++++++++++++++++++++++++++++++++++++++++ webui.go | 103 +++++++++++++++++++++++++++++++++++----------- webui_test.go | 1 + 5 files changed, 214 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 6c67601..f45e4a3 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ web UI 默认通过`http://localhost:51880`访问(端口对应`webPort`), | 监控 | 当前转换任务的进度卡片、添加主播、主播列表(默认打开的分区),以及运行日志 | | 录制 | 保存目录和子文件夹、录播格式、默认码率上限、ffmpeg 路径 | | 合并 | 自动合并开关、成片输出目录和合成码率、手动合并(可一次排多个任务,每个任务单独配参数) | -| 转码 | 把录播或成片重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 | +| 转码 | 把录播、成片或磁盘上任意位置的视频重新压一遍:每个文件单独配置编码器、码率、帧率、分辨率、音频,并预估产物大小 | | 通知 | 推送哪些事件,以及 QQ 机器人 / MeoW 两个渠道的配置和测试连通 | | 文件 | 磁盘剩余容量、录播和成片的文件列表 | | 设置 | 是否允许外网访问、管理口令的查看与更换 | @@ -271,9 +271,16 @@ web UI 的`合并`分区里的`手动合并`用来自己挑视频和弹幕出片 ### 转码 -`转码`分区用来把录播或成片重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。 +`转码`分区用来把视频重新压一遍:体积太大想压小、想换成 HEVC、想降分辨率或帧率、想换音轨,都在这里做。源文件不限于本程序录的录播和成片,磁盘上任何位置的文件都能丢进来。**转码不动原文件**,成片另存到输出目录(留空就是成片文件夹)。 -从文件列表里多选加入之后,每个文件一行,参数各自独立: +源文件有两个入口,挑哪个都行: + +- **从文件列表挑**:录播和成片文件夹里的文件列在左边,按 Ctrl 点可以多选,选中后点`加入列表`。 +- **自己填路径**:右边的输入框一行一个路径,可以是从资源管理器复制来的绝对路径,也可以是录播/成片文件夹里的文件名(只写文件名就按这两个文件夹找)。点`浏览…`可以从磁盘上任意位置挑,支持多选、可以跨文件夹跨盘攒,选完只往输入框里追加,不会清掉已经填好的。 + +两个入口进来的文件进的是同一个待转码列表,每条互不影响。 + +每个文件一行,参数各自独立: | 参数 | 说明 | | --- | --- | diff --git a/transcode.go b/transcode.go index ba63466..677e852 100644 --- a/transcode.go +++ b/transcode.go @@ -66,17 +66,29 @@ type transcodeOptions struct { Audio string `json:"audio"` // 音频处理,空 = 直接拷贝 } -// 一个待转码的文件 +// 一个待转码的文件。 +// 定位源文件有两种写法,二选一:Path 填了就用它(绝对路径、相对路径都行,可以是从资源管理器 +// 复制来的带引号路径),没填才按 Dir + Name 在录播/成片文件夹里找 type transcodeJobReq struct { + Path string `json:"path"` Dir string `json:"dir"` // record / merge Name string `json:"name"` // 文件名 OutName string `json:"outName"` // 输出文件名,留空自动 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 { Name string `json:"name"` + Path string `json:"path"` // 源文件绝对路径,页面拿它当任务的身份(同名文件在不同目录不会认错) Dir string `json:"dir"` Size int64 `json:"size"` Duration float64 `json:"duration"` @@ -531,9 +543,19 @@ func formatByteSize(n int64) string { 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 { return "", err } @@ -650,7 +672,7 @@ func runTranscode(job transcodeJobReq, outDir string, idx, total int) (string, e if ffmpegFile == "" { return "", errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径") } - in, err := resolveTranscodeInput(job.Dir, job.Name) + in, err := resolveTranscodeJob(job) if err != nil { return "", err } @@ -803,7 +825,7 @@ func transcodeProbeHandler(w http.ResponseWriter, r *http.Request) { out := make([]transcodeProbe, 0, len(req.Files)) for _, f := range req.Files { - full, err := resolveTranscodeInput(f.Dir, f.Name) + full, err := resolveTranscodeJob(f) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -815,7 +837,7 @@ func transcodeProbeHandler(w http.ResponseWriter, r *http.Request) { } m := probeMediaCached(ffmpegFile, full) 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, // 视频码率在这里就扣掉音轨:后面预估体积还要单独加一次音频, // 直接用容器总码率会把音轨算成两份 @@ -852,10 +874,10 @@ func transcodeEstimateHandler(w http.ResponseWriter, r *http.Request) { out := make([]transcodeEstimate, 0, len(req.Jobs)) for _, j := range req.Jobs { if msg := j.Options.validate(ffmpegFile); msg != "" { - http.Error(w, j.Name+":"+msg, http.StatusBadRequest) + http.Error(w, j.label()+":"+msg, http.StatusBadRequest) return } - full, err := resolveTranscodeInput(j.Dir, j.Name) + full, err := resolveTranscodeJob(j) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return @@ -907,10 +929,10 @@ func transcodeHandler(w http.ResponseWriter, r *http.Request) { // 探测走缓存,刚才「加入列表」和「估算体积」时已经探过,这里不会再多起 ffmpeg for _, j := range req.Jobs { if msg := j.Options.validate(ffmpegFile); msg != "" { - http.Error(w, j.Name+":"+msg, http.StatusBadRequest) + http.Error(w, j.label()+":"+msg, http.StatusBadRequest) return } - full, err := resolveTranscodeInput(j.Dir, j.Name) + full, err := resolveTranscodeJob(j) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/transcode_test.go b/transcode_test.go index 31fe5e7..91fd47e 100644 --- a/transcode_test.go +++ b/transcode_test.go @@ -1,10 +1,14 @@ package main import ( + "encoding/json" "math" + "net/http" + "net/http/httptest" "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" ) @@ -477,6 +481,94 @@ func has(args []string, v string) bool { 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 { for i, a := range args { if a == v { diff --git a/webui.go b/webui.go index 196ae1f..887fbd1 100644 --- a/webui.go +++ b/webui.go @@ -439,13 +439,24 @@ const webuiHTML = `
-

选择要转码的文件

从录播和成片文件夹里挑
-
- -
- - - 按住 Ctrl 点可以一次选多个 +

选择要转码的文件

从录播/成片文件夹挑,或者自己填路径
+
+
+ + +
+ + +
+
+
+ + +
+ + + +
加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。
@@ -1030,6 +1041,7 @@ async function loadFiles(b){ var d = await r.json(); fileList = d.files || []; recDir = d.recordDir || ""; + mergeDir = d.mergeDir || ""; fillMergePickers(); fillTranscodePick(); document.getElementById("diskInfo").textContent = @@ -1078,11 +1090,13 @@ var pickMulti = false; // 多选模式(视频框这种一行一个、可以选 var pickCb = null; // 选完后交给它的回调,不为空时不再往输入框里填 var pickSel = []; // 多选已勾选的绝对路径,换目录也不清,所以能跨文件夹/跨盘攒 var recDir = ""; // 录播目录,挑弹幕和视频时从它开始 +var mergeDir = ""; // 成片目录,转码挑文件时从它开始(比空的录播目录更有东西可挑) var lastPickKey = "", lastPickAt = 0; // 列表里连点同一项只算一次 var lastNavAt = 0; // 刚跳转过目录的这段时间里忽略列表上的点击,双击文件夹才不会连进两层 -// cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用) -function openPick(id, kind, multi, cb){ +// cb 不为空时选完不填框,而是把选中的路径交给它(手动合并、转码这种一行一个的列表用); +// startAt 不为空时从它开始(不给就按框里已填的内容推) +function openPick(id, kind, multi, cb, startAt){ var el = id ? document.getElementById(id) : null; pickField = id; 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.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); } function closePick(){ document.getElementById("pick").classList.remove("on"); } @@ -1573,37 +1587,76 @@ function defaultTranscodeOpts(){ function defaultTranscodeOut(p){ 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(){ var sel = document.getElementById("tcFilePick"); - var picks = []; + var cands = []; Array.prototype.forEach.call(sel.options, function(o){ - if(o.selected){ - 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(!o.selected) return; + var f = tcVideos[parseInt(o.value, 10)]; + 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; try{ 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){} if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); return; } if(!r || !r.ok){ showMsg(false, r ? await r.text() : "连接失败"); return; } var d = null; try{ d = await r.json(); }catch(e){ showMsg(false, "读文件信息失败"); return; } if(d && d.encoders && d.encoders.length) tcEncoders = d.encoders; - var skipped = []; + var skipped = [], dup = 0, added = 0; (d.files || []).forEach(function(p){ // 只有音轨(或只有封面图)的文件排进去也是白排,ffmpeg 会直接报没视频流 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, - fps: p.fps, vkbps: p.videoKbps, akbps: p.audioKbps, audio: !!p.hasAudio, codec: p.videoCodec, - out: defaultTranscodeOut(p), opts: defaultTranscodeOpts(), est: null}); + var key = tcJobKey(p); + if(tcJobs.some(function(j){ return tcJobKey(j) === key; })){ dup++; return; } + 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(); estimateTranscode(); if(skipped.length) showMsg(false, "这些文件里没有视频轨,已跳过:" + skipped.join("、")); + else if(!added && dup) showMsg(false, "选中的文件已经在列表里了"); } function clearTranscodeFiles(){ if(tcBusy) return; @@ -1655,7 +1708,7 @@ function renderTranscodeRows(){ return h === 0 || !job.h || h < job.h; }); return '
' - + '
' + esc(job.name) + '' + + '
' + esc(job.name) + '' + '' + esc(meta) + '' + '
' + '
' @@ -1728,7 +1781,7 @@ function scheduleEstimate(){ async function estimateTranscode(){ if(!tcJobs.length){ updateTranscodeTotal(); return; } 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; try{ 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; if(!confirm(n > 1 ? ("开始转码这 " + n + " 个文件?会按列表顺序一个一个来。") : "开始转码?")) return; 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"); b.disabled = true; var r = null; diff --git a/webui_test.go b/webui_test.go index 85da166..5a29d0b 100644 --- a/webui_test.go +++ b/webui_test.go @@ -350,6 +350,7 @@ func TestWebuiSettingFieldsMatchInputs(t *testing.T) { "pickPath": "路径选择弹窗的内部状态", "mrgVideoPick": "手动合并:视频列表,随合并请求提交", "tcFilePick": "转码:待选文件列表,随转码请求提交", + "tcPaths": "转码:手填的文件路径列表,随转码请求提交", "tcOutDir": "转码:输出目录,随转码请求提交", } for id := range ids {