Files
acfunlive-remake/manualmerge.go
T
tamakyi 8e82aa54c6 修第二批 P1:合并收尾、分块上限、确定性失败与 ffmpeg 路径归一
一、合并收尾只删真正进了成片的分段
   prepareForMerge 中途有 4 处会回退成「只合并第一个分段」,而收尾仍按原分段列表
   全删源视频 —— 后半场没进成片却照样被删掉,等于永久丢数据。现在 prepareForMerge
   把「实际参与合并的源文件」一并返回(新增 used),收尾只删这些。

二、拼接中断不再在录播目录留几 GB 中间文件
   concat 失败分支原来只有「被停止」那条路径删了 acfunlive_chunk_joined.*,
   普通失败会把它留在录播目录,而且带着 chunkTempPrefix,界面文件列表里根本看不见。

三、把「被停止」和「拼接失败」分开
   停止任务会杀掉 ffmpeg 让它非 0 退出,原来一律报成「拼接失败」,
   卡片上显示的是失败而不是已停止;prepareForMerge 那里也统一改用 errors.Is 判断。

四、单块弹幕条数加硬上限(maxDanmuPerChunkHard = 200)
   块尾会把「显示时间跨过块尾」的弹幕一并并入,弹幕密集时单块能膨胀到几百条。
   一条弹幕在 -filter_complex 里约占 110 字节,几百条足以顶破 Windows 的
   32767 字符命令行上限,表现同样是「合并失败且看不出原因」。到上限就把块尾
   收到下一条弹幕出现之前,保证被挤到下一块的弹幕不会因为「整条早于块头」被丢弃。

五、确定性失败不再白重试三次
   头像超出体积/尺寸上限这类失败重试多少次结果都一样,原来会白白把十几 MB 重新下三遍。
   新增 noRetryError(runThrice 用 errors.As 识别)只标记这两个上限,网络类错误照旧重试。

六、ffmpeg 路径统一归一成绝对路径
   PATH 里带「.」这类相对条目、或程序目录探测失败时,getFFmpeg 会返回相对路径。
   合并时 ffmpeg 工作目录被改成输出目录,相对路径到那边就找不着了;而且 Windows 上
   exec 会直接拒绝执行「相对当前目录」找到的可执行文件,报错还跟路径完全看不出关系。

验证:
- gofmt -l 空;go vet ./... 空;go build ./... 与 GOOS=linux go build 通过
- go test ./... 全绿(10.8s);go test -race ./... 全绿(14.7s)
- 新增 chunks_test.go 10 个用例;6 项修复逐条反向验证(临时退回修复 → 对应测试确实失败)
- 51897 隔离实例上新产物实测:e2e.py 24 项全过;smoke_extra.py 34 项全过
  (含并发合并返回 409 并报出在跑的任务名、口令失败后正确口令仍立刻可用、
  超大头像被拒不误伤正常头像且只尝试 1 次、停止后无临时文件残留)
2026-09-26 09:35:51 +08:00

654 lines
21 KiB
Go
Raw 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"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// 允许作为合并输入的视频扩展名
var mergeVideoExts = map[string]struct{}{
".mp4": {}, ".m4v": {}, ".flv": {}, ".ts": {}, ".mkv": {}, ".mov": {}, ".webm": {},
}
// 允许作为合并输入的弹幕扩展名
var mergeDanmuExts = map[string]struct{}{".jsonl": {}, ".ass": {}}
// 手动合并的请求体
type mergeRequest struct {
Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径
Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl
Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算
OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名
}
// 检查结果里每个视频的信息
type mergeVideoInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
Duration float64 `json:"duration"`
InUse bool `json:"inUse"`
}
// 手动合并的检查结果,页面直接展示
type mergeInfo struct {
Videos []mergeVideoInfo `json:"videos"`
DanmuName string `json:"danmuName"`
DanmuCount int `json:"danmuCount"`
DanmuFirst float64 `json:"danmuFirst"`
DanmuLast float64 `json:"danmuLast"`
DanmuHasAvatar bool `json:"danmuHasAvatar"`
Source string `json:"source"` // 弹幕数据的来源说明(jsonl 还是从 ass 还原)
Offset float64 `json:"offset"`
OffsetNote string `json:"offsetNote"`
OutName string `json:"outName"`
Duration float64 `json:"duration"` // 视频总时长(秒)
Warnings []string `json:"warnings"`
}
// 检查通过后解析好的合并方案,避免检查一次、合成时又重新解析一遍
type mergePlan struct {
videos []string // 视频的绝对路径,已按录制时间排好序
danmu string // 弹幕文件的绝对路径
out string // 成片的绝对路径
meta danmuMeta
offset *float64
info mergeInfo
}
// 手动合并的运行状态,页面轮询它来决定按钮是否可用
var mergeRun struct {
sync.Mutex
busy bool
outName string
lastErr string
doneAt int64
}
// 手动合并是否正在进行
func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) {
mergeRun.Lock()
defer mergeRun.Unlock()
return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt
}
// 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径
func resolveMergeInput(value string) (string, error) {
value = strings.TrimSpace(value)
// 去掉从资源管理器复制路径时带上的引号
value = strings.Trim(value, `"'`)
if value == "" {
return "", errors.New("文件路径为空")
}
if value == "." || value == ".." || strings.ContainsAny(value, "\x00\r\n") {
return "", errors.New("文件路径不合法")
}
var cands []string
if filepath.IsAbs(value) {
cands = append(cands, value)
} else {
// 不是绝对路径时优先当成录播目录里的文件名,这样页面下拉里选出来的名字能直接填
cands = append(cands, filepath.Join(recordSaveDir(), value))
if mergeDir := mergeSaveDir(); normalizePath(mergeDir) != normalizePath(recordSaveDir()) {
cands = append(cands, filepath.Join(mergeDir, value))
}
cands = append(cands, value) // 相对当前工作目录
}
for _, c := range cands {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
full, err := filepath.Abs(c)
if err != nil {
continue
}
return full, nil
}
}
return "", fmt.Errorf("找不到文件:%s", value)
}
// 找同名弹幕文件时按这个顺序试:本程序录的 .jsonl 带头像链接,优先用
var mergeDanmuCandidates = []string{".jsonl", ".ass", ".nick.ass"}
// 按视频文件找同目录下的同名弹幕文件,找不到返回空字符串。
// 同目录找不到时再拿文件名去录播目录里试一次(视频被挪走、弹幕还留在录播目录的情况)
func findDanmuForVideo(video string) string {
full, err := resolveMergeInput(video)
if err != nil {
return ""
}
base := strings.TrimSuffix(full, filepath.Ext(full))
dirs := []string{filepath.Dir(full)}
if recDir := recordSaveDir(); normalizePath(recDir) != normalizePath(filepath.Dir(full)) {
dirs = append(dirs, recDir)
}
for _, dir := range dirs {
for _, ext := range mergeDanmuCandidates {
cand := filepath.Join(dir, filepath.Base(base)+ext)
if info, err := os.Stat(cand); err == nil && !info.IsDir() {
return cand
}
}
}
return ""
}
// 填回输入框时的写法:录播目录/成片目录里的只写文件名,别处的写绝对路径(和浏览弹窗一致)
func mergeFillValue(path string) string {
dir := normalizePath(filepath.Dir(path))
if dir == normalizePath(recordSaveDir()) || dir == normalizePath(mergeSaveDir()) {
return filepath.Base(path)
}
return path
}
// GET /api/merge/danmu?video=xxx —— 页面选好视频后自动找同名弹幕文件用
func mergeDanmuMatchHandler(w http.ResponseWriter, r *http.Request) {
path := findDanmuForVideo(r.URL.Query().Get("video"))
resp := map[string]string{"danmu": ""}
if path != "" {
resp["danmu"] = mergeFillValue(path)
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
}
// 扩展名白名单的说明文字
func extListText(m map[string]struct{}) string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return strings.Join(out, " ")
}
// 从文件名前缀推出录制开始的时刻
// 本程序(以及上游原版)的文件名都是「2006-01-02 15-04-05 主播名 标题」
func nameStartNs(path string) int64 {
name := filepath.Base(path)
if len(name) < 19 {
return 0
}
t, err := time.ParseInLocation("2006-01-02 15-04-05", name[:19], time.Local)
if err != nil {
return 0
}
return t.UnixNano()
}
// 文件名里没有时间戳时,用「修改时间 - 时长」估算视频的起点
func estimateStartNs(ffmpegFile, path string) int64 {
d := probeDuration(ffmpegFile, path)
if d <= 0 {
return 0
}
info, err := os.Stat(path)
if err != nil {
return 0
}
return info.ModTime().UnixNano() - int64(d*1e9)
}
// 推测弹幕相对视频起点的时间偏移:视频时间 = 弹幕时间 + 偏移
func guessOffset(videos []string, danmuPath string, meta danmuMeta) (float64, string) {
if len(videos) == 1 {
videoBase := strings.TrimSuffix(filepath.Base(videos[0]), filepath.Ext(videos[0]))
if strings.EqualFold(danmuBaseName(danmuPath), videoBase) {
return 0, "视频和弹幕文件名一致,按同一场录制处理"
}
}
danmuStart, fromMeta := nameStartNs(danmuPath), false
if danmuStart == 0 && meta.StartTime > 0 {
danmuStart, fromMeta = meta.StartTime, true
}
if danmuStart == 0 {
return 0, "弹幕文件里没有录制开始时间,偏移按 0 处理"
}
videoStart := int64(0)
if len(videos) > 0 {
videoStart = nameStartNs(videos[0])
if videoStart == 0 {
videoStart = estimateStartNs(getFFmpeg(), videos[0])
}
}
if videoStart == 0 {
return 0, "无法确定视频的开始时间,偏移按 0 处理"
}
off := float64(danmuStart-videoStart) / 1e9
note := "按文件名里的录制时间推算"
if fromMeta {
note = "视频按文件名时间、弹幕按 jsonl 里记录的开始时间推算"
}
if math.Abs(off) > 60 {
note += ";偏移偏大,请确认选的是同一场录制"
}
return off, note
}
// 准备弹幕源:.jsonl 直接用;.ass / .nick.ass 还原成 jsonl
// write 为 false 时只解析不落盘(页面上的「检查」用)
type danmuSource struct {
jsonlPath string
meta danmuMeta
count int
first float64
last float64
hasAvatar bool
source string // 来源说明
}
func prepareDanmuSource(path string, write bool) (danmuSource, error) {
if strings.EqualFold(filepath.Ext(path), ".jsonl") {
sum, err := scanJsonlSummary(path)
if err != nil {
return danmuSource{}, err
}
return danmuSource{jsonlPath: path, meta: sum.meta, count: sum.count,
first: sum.first, last: sum.last, hasAvatar: sum.hasAvatar,
source: "jsonl 弹幕数据"}, nil
}
// 旁边已经有同名的 jsonl(本程序录制的就有),它带着头像链接,比从 .ass 还原更好
jsonlPath := assSiblingJsonl(path)
if info, err := os.Stat(jsonlPath); err == nil && info.Size() > 0 {
if sum, err := scanJsonlSummary(jsonlPath); err == nil && sum.count > 0 {
lPrintf("发现同名的弹幕数据文件 %s,直接用(比 .ass 多了头像链接)", filepath.Base(jsonlPath))
return danmuSource{jsonlPath: jsonlPath, meta: sum.meta, count: sum.count,
first: sum.first, last: sum.last, hasAvatar: sum.hasAvatar,
source: "同名的 jsonl 弹幕数据"}, nil
}
}
meta, records, err := parseAssFile(path)
if err != nil {
return danmuSource{}, err
}
src := danmuSource{jsonlPath: jsonlPath, meta: meta, count: len(records),
first: records[0].Time, last: records[len(records)-1].Time,
source: "由 " + filepath.Base(path) + " 还原"}
if !write {
return src, nil
}
if err := writeJsonlFile(jsonlPath, meta, records); err != nil {
// 目录只读等情况退到临时目录,不影响这次合并
tmp := filepath.Join(os.TempDir(), chunkTempPrefix+"converted.jsonl")
if err2 := writeJsonlFile(tmp, meta, records); err2 != nil {
return src, fmt.Errorf("还原弹幕数据失败:%v", err)
}
lPrintWarnf("无法把还原结果写入 %s(%v),改用临时文件 %s", jsonlPath, err, tmp)
src.jsonlPath = tmp
return src, nil
}
lPrintf("已把 %s 还原成 %s", filepath.Base(path), filepath.Base(jsonlPath))
return src, nil
}
// 输出文件名只能是文件名本身,且固定为 mp4
func sanitizeOutName(name string) (string, error) {
name = strings.TrimSpace(name)
if name == "" {
return "", errors.New("输出文件名不能为空")
}
if name == "." || name == ".." || strings.ContainsAny(name, "\x00\r\n") ||
name != filepath.Base(filepath.FromSlash(name)) || strings.ContainsAny(name, `/\`) {
return "", errors.New("输出文件名不合法,只能填文件名本身")
}
if !strings.EqualFold(filepath.Ext(name), ".mp4") {
name += ".mp4"
}
return name, nil
}
// 找一个不冲突的输出路径,不默默覆盖上一次的成片
func freeOutPath(dir, base string) string {
cand := filepath.Join(dir, base+".mp4")
if _, err := os.Stat(cand); err != nil {
return cand
}
for i := 2; i < 1000; i++ {
p := filepath.Join(dir, fmt.Sprintf("%s(%d).mp4", base, i))
if _, err := os.Stat(p); err != nil {
return p
}
}
return cand
}
// 解析请求、检查文件并算出合并参数
func buildMergePlan(req mergeRequest) (mergePlan, error) {
var plan mergePlan
// 视频:一行一个,允许夹带空行
var values []string
for _, v := range req.Videos {
for _, p := range strings.FieldsFunc(v, func(r rune) bool { return r == '\n' || r == '\r' }) {
if t := strings.TrimSpace(p); t != "" {
values = append(values, t)
}
}
}
if len(values) == 0 {
return plan, errors.New("请先选择要合并的视频文件")
}
for _, v := range values {
full, err := resolveMergeInput(v)
if err != nil {
return plan, err
}
if _, ok := mergeVideoExts[strings.ToLower(filepath.Ext(full))]; !ok {
return plan, fmt.Errorf("不支持的视频格式 %s,支持 %s", filepath.Ext(full), extListText(mergeVideoExts))
}
if isFileActive(full) {
return plan, fmt.Errorf("%s 正在录制或写入中,等它结束再合并", filepath.Base(full))
}
plan.videos = append(plan.videos, full)
}
if strings.TrimSpace(req.Danmu) == "" {
return plan, errors.New("请先选择弹幕文件")
}
danmuPath, err := resolveMergeInput(req.Danmu)
if err != nil {
return plan, err
}
if _, ok := mergeDanmuExts[strings.ToLower(filepath.Ext(danmuPath))]; !ok {
return plan, fmt.Errorf("不支持的弹幕格式 %s,支持 %s", filepath.Ext(danmuPath), extListText(mergeDanmuExts))
}
if isFileActive(danmuPath) {
return plan, fmt.Errorf("%s 正在录制或写入中,等它结束再合并", filepath.Base(danmuPath))
}
plan.danmu = danmuPath
// 先只解析不落盘,检查阶段不应该产生文件
src, err := prepareDanmuSource(danmuPath, false)
if err != nil {
return plan, err
}
if src.count == 0 {
return plan, errNoDanmu
}
plan.meta = src.meta
// 按录制的开始时间排序,用户填反了也能自动纠正
sort.SliceStable(plan.videos, func(i, j int) bool {
si, sj := nameStartNs(plan.videos[i]), nameStartNs(plan.videos[j])
if si == 0 || sj == 0 {
return plan.videos[i] < plan.videos[j]
}
return si < sj
})
info := mergeInfo{
DanmuName: filepath.Base(danmuPath),
DanmuCount: src.count,
DanmuFirst: src.first,
DanmuLast: src.last,
DanmuHasAvatar: src.hasAvatar,
Source: src.source,
}
ffmpegFile := getFFmpeg()
for _, f := range plan.videos {
fi, err := os.Stat(f)
if err != nil {
return plan, fmt.Errorf("读不到文件信息:%s", filepath.Base(f))
}
d := 0.0
if ffmpegFile != "" {
d = probeDuration(ffmpegFile, f)
}
info.Duration += d
info.Videos = append(info.Videos, mergeVideoInfo{
Name: filepath.Base(f), Size: fi.Size(), Duration: d, InUse: isFileActive(f),
})
}
if ffmpegFile == "" {
return plan, errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
}
info.Offset, info.OffsetNote = guessOffset(plan.videos, danmuPath, src.meta)
if req.Offset != nil {
plan.offset = req.Offset
info.Offset = *req.Offset
info.OffsetNote = "按你填写的偏移"
}
mergeDir := mergeSaveDir()
if strings.TrimSpace(req.OutName) == "" {
plan.out = freeOutPath(mergeDir, danmuBaseName(danmuPath)+"_弹幕合并")
} else {
name, err := sanitizeOutName(req.OutName)
if err != nil {
return plan, err
}
plan.out = filepath.Join(mergeDir, name)
}
info.OutName = filepath.Base(plan.out)
if !src.hasAvatar {
info.Warnings = append(info.Warnings,
"这个弹幕文件里没有头像链接,合成时会按 uid 向 AcFun 反查头像;个别查不到的只烧文字")
}
if info.Duration > 0 && src.last+info.Offset >= info.Duration {
info.Warnings = append(info.Warnings, fmt.Sprintf(
"最后一条弹幕在 %.1f 秒,加上偏移后已超出视频总长 %.1f 秒,超出的弹幕会被丢掉;"+
"如果这个视频只是整场的一段,请把同场的其它分段一起加进来",
src.last+info.Offset, info.Duration))
}
if info.Duration == 0 {
info.Warnings = append(info.Warnings, "读不出视频时长,合成时无法裁掉超出视频的弹幕")
}
plan.info = info
return plan, nil
}
// 真正执行合并,进度既打到运行日志里,也同步给 web UI 的任务卡片
func runManualMerge(plan mergePlan) error {
ffmpegFile := getFFmpeg()
if ffmpegFile == "" {
return errors.New("找不到 ffmpeg,请先在设置里填写 ffmpeg 路径")
}
// 先把任务的输入大小和总时长登记下来,界面在 ffmpeg 跑起来之前就有东西可显示
inputBytes := int64(0)
for _, v := range plan.info.Videos {
inputBytes += v.Size
}
if !jobStart("手动合并", plan.info.OutName, inputBytes, plan.info.Duration) {
return errors.New("已经有别的合并任务在跑,请先等它结束")
}
src, err := prepareDanmuSource(plan.danmu, true)
if err != nil {
return err
}
// 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴
segs := make([]recordSegment, 0, len(plan.videos))
for _, f := range plan.videos {
start := nameStartNs(f)
if start == 0 {
start = estimateStartNs(ffmpegFile, f)
}
if start == 0 {
start = time.Now().UnixNano()
}
segs = append(segs, recordSegment{file: f, startNs: start})
}
if len(segs) > 1 {
lPrintf("手动合并:%d 个分段按录制时间顺序拼成一个视频后再合成", len(segs))
}
danmuStartNs := nameStartNs(plan.danmu)
if danmuStartNs == 0 {
danmuStartNs = src.meta.StartTime
}
if danmuStartNs == 0 {
danmuStartNs = segs[0].startNs
}
// 复用自动合并那条链路:多段会先拼接并把弹幕时间重映射到拼接后的时间轴
// (手动合并不删源视频,所以这里用不上「实际参与合并的文件」)
video, jsonl, autoOffset, _, cleanup, err := prepareForMerge(ffmpegFile, segs, src.jsonlPath, danmuStartNs)
defer cleanup()
if err != nil {
return err
}
offset, note := autoOffset, "按文件名里的录制时间自动推算"
if plan.offset != nil {
offset, note = *plan.offset, "按你填写的偏移"
}
if math.Abs(offset) > 60 {
lPrintWarnf("偏移 %.1f 秒偏大,如果弹幕整体对不上画面,调整偏移后重新合并", offset)
}
if err := os.MkdirAll(filepath.Dir(plan.out), 0755); err != nil {
return fmt.Errorf("创建成片文件夹失败:%v", err)
}
if _, err := os.Stat(plan.out); err == nil {
lPrintWarn("成片已存在,将被覆盖:" + plan.out)
}
// 先在本地(弹幕所在的录播目录)合成,完成后再落到成片目录:
// 成片目录可能是网络盘或外接盘,直接往里写、写到一半失败会在那里留个半成品
tmpOut := tempMergeOutput(plan.danmu, filepath.Base(plan.out))
lPrintf("手动合并开始:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)",
filepath.Base(video), filepath.Base(jsonl), offset, note)
lPrintf("先在本地合成 %s,完成后落到 %s", filepath.Base(tmpOut), plan.out)
// 标记成正在写入,避免在文件管理里把还没写完的成片删掉
markFileActive(tmpOut, true)
burnErr := burnDanmu(jsonl, video, tmpOut, offset)
markFileActive(tmpOut, false)
if burnErr != nil {
_ = os.Remove(tmpOut)
return burnErr
}
jobSetPhase(jobPhasePlace)
if err := placeOutput(tmpOut, plan.out); err != nil {
_ = os.Remove(tmpOut)
return err
}
lPrintln("手动合并完成:" + plan.out)
// 通知:手动合并没有 uid,能从文件名对上哪个主播就带上名字,对不上就用通用文案
uid := guessUIDByFileName(plan.out)
if uid == 0 {
uid = guessUIDByFileName(video)
}
text := "手动合并完成\n" + plan.out
if uid > 0 {
text = longID(uid) + " 的录播和弹幕(手动合并)完成\n" + plan.out
}
sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text)
return nil
}
// 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成
func mergeInfoHandler(w http.ResponseWriter, r *http.Request) {
var req mergeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
plan, err := buildMergePlan(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(plan.info); err != nil {
lPrintErrf("输出合并检查结果失败:%v", err)
}
}
// 开始手动合并:立刻返回,合成在后台进行
func mergeHandler(w http.ResponseWriter, r *http.Request) {
var req mergeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "请求不是合法的 json:"+err.Error(), http.StatusBadRequest)
return
}
plan, err := buildMergePlan(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
mergeRun.Lock()
if mergeRun.busy {
busyOut := mergeRun.outName
mergeRun.Unlock()
http.Error(w, "已经有一个手动合并在进行("+busyOut+"),等它结束再说", http.StatusConflict)
return
}
// 任务卡片只有一份:自动合并也可能占着它,那时手动的也得等
if !tryTakeMergeSlot() {
mergeRun.Unlock()
busy := jobBusyName()
if busy == "" {
busy = "另一个合并任务"
}
http.Error(w, "已经有一个合并在进行("+busy+"),等它结束再说", http.StatusConflict)
return
}
mergeRun.busy = true
mergeRun.outName = plan.info.OutName
mergeRun.lastErr = ""
mergeRun.Unlock()
safeGo("手动合并", func() {
defer releaseMergeSlot()
defer func() {
mergeRun.Lock()
mergeRun.busy = false
mergeRun.doneAt = time.Now().Unix()
mergeRun.Unlock()
}()
err := runManualMerge(plan)
// 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上
jobFinish(jobErrText(err))
if err == nil {
return
}
msg := err.Error()
switch err {
case errNoDanmu:
msg = "弹幕文件里没有弹幕"
lPrintWarn("弹幕文件里没有弹幕,取消手动合并")
case errJobStopped:
// 手动停止不是失败,别让界面弹「合并失败」
lPrintWarn("手动合并已被手动停止")
mergeRun.Lock()
mergeRun.lastErr = ""
mergeRun.Unlock()
return
default:
lPrintErrf("手动合并失败:%v", err)
}
mergeRun.Lock()
mergeRun.lastErr = msg
mergeRun.Unlock()
})
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"outName":` + strconv.Quote(plan.info.OutName) + `}`))
}