转码的源文件不再限死在录播/成片目录,可以填任意路径

为什么
  转码页的多选框只列 /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 发、重复加入不重复、浏览弹窗打开/追加不
  覆盖/关闭/清空)。
This commit is contained in:
tamakyi
2026-09-27 11:10:00 +08:00
parent a409c5b4dd
commit 3c17503a21
5 changed files with 214 additions and 39 deletions
+78 -25
View File
@@ -439,13 +439,24 @@ const webuiHTML = `<!DOCTYPE html>
<!-- 转码 -->
<section class="pane" data-pane="trc">
<div class="card">
<div class="card-h"><h2>选择要转码的文件</h2><span class="sub">从录播和成片文件夹里挑</span></div>
<div class="bar" style="margin-top:0">
<select id="tcFilePick" multiple size="6" style="flex:1 1 260px;min-width:0"></select>
<div style="flex:1 1 160px;display:flex;flex-direction:column;gap:8px">
<button class="ghost" onclick="addTranscodeFiles()">加入列表</button>
<button class="ghost" onclick="clearTranscodeFiles()">清空列表</button>
<span class="hint" style="margin:0">按住 Ctrl 点可以一次选多个</span>
<div class="card-h"><h2>选择要转码的文件</h2><span class="sub">从录播/成片文件夹挑,或者自己填路径</span></div>
<div class="grid">
<div class="f">
<label>从文件列表挑(可多选,按 Ctrl 点选)</label>
<select id="tcFilePick" multiple size="6"></select>
<div class="bar">
<button class="ghost" onclick="addTranscodeFiles()">加入列表</button>
<button class="ghost" onclick="clearTranscodeFiles()">清空列表</button>
</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 class="hint">加入列表后会先读一遍每个文件的分辨率、帧率、码率和时长,参数按读出来的值给默认。转码不动原文件,成片另存到下面的输出目录。</div>
@@ -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 '<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>'
+ '<button class="mini" onclick="removeTranscodeFile(' + i + ')">移除</button></div>'
+ '<div class="tg">'
@@ -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;