// 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": {}} // 手动合并的请求体 // // 两种写法:只填 videos/danmu 是单个任务(老样子);填 jobs 是一次排多个任务, // 每个任务自带视频、弹幕和合成参数,服务端按顺序串行合并 type mergeRequest struct { Videos []string `json:"videos"` // 视频文件,可多个(断流分段):文件名或绝对/相对路径 Danmu string `json:"danmu"` // 弹幕文件:.jsonl 直接用,.ass / .nick.ass 先还原成 jsonl Offset *float64 `json:"offset"` // 手动指定的偏移(秒),不填则按文件名里的录制时间推算 OutName string `json:"outName"` // 输出文件名,留空用默认名且自动避开重名 Options mergeOptions `json:"options"` // 这一次合成的参数,不填的项用全局设置 Jobs []mergeJobReq `json:"jobs"` // 批量:每个任务一套自己的文件和参数 } // 批量里的一个合并任务 type mergeJobReq struct { Videos []string `json:"videos"` // 这一组的视频,多个就是同一场的断流分段,会先拼起来 Danmu string `json:"danmu"` // 这一组的弹幕文件 Offset *float64 `json:"offset"` // 这一组的偏移 OutName string `json:"outName"` // 这一组的输出文件名 Options mergeOptions `json:"options"` // 这一组的合成参数 } // 一次最多排多少个合并任务。再多就得等太久,队列状态也会一直占着 const maxMergeJobs = 50 // 批量任务里每个任务的结果,页面按它显示「第几个、成功还是失败」 type mergeJobResult struct { OutName string `json:"outName"` Err string `json:"err"` // 空表示成功 } func (r mergeJobResult) ok() bool { return r.Err == "" } // 检查结果里每个视频的信息 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 opts mergeOptions // 这一组自己的合成参数 info mergeInfo } // 手动合并的运行状态,页面轮询它来决定按钮是否可用 var mergeRun struct { sync.Mutex busy bool outName string lastErr string doneAt int64 // 批量合并的进度:一共几个、已经做完几个、每个的结果 total int done int results []mergeJobResult } // 手动合并是否正在进行 func mergeRunSnapshot() (busy bool, out string, lastErr string, doneAt int64) { mergeRun.Lock() defer mergeRun.Unlock() return mergeRun.busy, mergeRun.outName, mergeRun.lastErr, mergeRun.doneAt } // 批量合并的队列进度,页面用它显示「第 2/3 个」和各任务的结果 func mergeQueueSnapshot() (busy bool, done, total int, results []mergeJobResult) { mergeRun.Lock() defer mergeRun.Unlock() out := make([]mergeJobResult, len(mergeRun.results)) copy(out, mergeRun.results) return mergeRun.busy, mergeRun.done, mergeRun.total, out } // 解析用户填的路径:可以先当文件名在录播/成片文件夹里找,也可以填绝对或相对路径 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 // 来源说明 // 从 .ass 还原出来的结果写不进原目录、退到系统临时目录时置位: // 这种文件调用方用完必须删掉,别在临时目录里越攒越多 tempJsonl bool } 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, bad, 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 } // 还原日志只在真正落盘这次打:页面的「检查」也会解析一遍同一个 .ass, // 两处都打日志的话同一次还原会在日志里出现两遍 summary := fmt.Sprintf("已从 %s 还原出 %d 条弹幕(%dx%d,字号 %d,弹幕到 %s)", filepath.Base(path), len(records), meta.PlayResX, meta.PlayResY, meta.FontSize, formatSeconds(records[len(records)-1].Time)) 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 src.tempJsonl = true lPrintf("%s,写成 %s", summary, filepath.Base(tmp)) logAssBadLines(path, bad) return src, nil } lPrintf("%s,写成 %s", summary, filepath.Base(jsonlPath)) logAssBadLines(path, bad) return src, nil } // .ass 里有行没能解析出来时提醒一次(只在真正落盘那次调,避免重复) func logAssBadLines(path string, bad int) { if bad > 0 { lPrintWarnf("%s 里有 %d 行弹幕没能解析出来,已跳过", filepath.Base(path), bad) } } // .ass 还原结果退到系统临时目录时,用完就删。只删标记过的临时文件: // 正常情况 jsonlPath 是用户录播目录里的同名 jsonl,删了就是丢数据 func dropTempDanmu(src danmuSource) { if src.tempJsonl { _ = os.Remove(src.jsonlPath) } } // 输出文件名只能是文件名本身,且固定为 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 } // 把一次请求拆成若干个合并任务并逐个检查。全部检查通过才返回: // 排到第 3 个才发现文件不对、前两个已经合完了,这种情况很难跟用户解释 func buildMergePlans(req mergeRequest) ([]mergePlan, error) { if len(req.Jobs) == 0 { plan, err := buildMergePlan(req) if err != nil { return nil, err } return []mergePlan{plan}, nil } if len(req.Jobs) > maxMergeJobs { return nil, fmt.Errorf("一次最多排 %d 个合并任务,这次有 %d 个", maxMergeJobs, len(req.Jobs)) } plans := make([]mergePlan, 0, len(req.Jobs)) for i, job := range req.Jobs { plan, err := buildMergePlan(mergeRequest{ Videos: job.Videos, Danmu: job.Danmu, Offset: job.Offset, OutName: job.OutName, Options: job.Options, }) if err != nil { return nil, fmt.Errorf("第 %d 个任务:%v", i+1, err) } plans = append(plans, plan) } return plans, nil } // 解析请求、检查文件并算出合并参数 func buildMergePlan(req mergeRequest) (mergePlan, error) { var plan mergePlan ffmpegFile := getFFmpeg() plan.opts = req.Options if msg := req.Options.validate(ffmpegFile); msg != "" { return plan, errors.New(msg) } // 视频:一行一个,允许夹带空行 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, } 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 的任务卡片。 // idx / total 是批量队列里的位置(单任务传 1, 1),只用来在日志和界面上标「第几个」 func runManualMerge(plan mergePlan, idx, total int) 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(jobKindManualMerge, plan.info.OutName, inputBytes, plan.info.Duration) { return errJobNotStarted } if idx > 0 && total > 0 { jobQueue(idx, total) } src, err := prepareDanmuSource(plan.danmu, true) if err != nil { return err } // 还原结果写不进原目录时用的是系统临时目录里的文件,合并完就删掉 defer dropTempDanmu(src) // 每一段的录制开始时刻取自文件名,用来算偏移和拼接后的时间轴 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)) // 批量合并时在日志里标出这是第几个,翻日志的时候对得上号 tag := "" if total > 1 { tag = fmt.Sprintf("(第 %d/%d 个)", idx, total) } lPrintf("手动合并开始%s:视频 %s,弹幕 %s,偏移 %.3f 秒(%s)", tag, 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, plan.opts) 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("手动合并完成" + tag + ":" + 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 } if total > 1 { text += fmt.Sprintf("\n(第 %d/%d 个)", idx, total) } sendNotify(notifyEventMergeDone, uid, nameForLog(uid), "", "", plan.out, text) return nil } // 检查合并参数,回显时长、弹幕条数和推测的偏移,不做实际合成。 // 请求里带 jobs 时一次检查多个任务,返回 {jobs:[...]};单个任务仍直接返回 info 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 } plans, err := buildMergePlans(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } w.Header().Set("Content-Type", "application/json") if len(req.Jobs) == 0 { if err := json.NewEncoder(w).Encode(plans[0].info); err != nil { lPrintErrf("输出合并检查结果失败:%v", err) } return } infos := make([]mergeInfo, 0, len(plans)) for _, p := range plans { infos = append(infos, p.info) } if err := json.NewEncoder(w).Encode(map[string]any{"jobs": infos}); 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 } plans, err := buildMergePlans(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 = plans[0].info.OutName mergeRun.lastErr = "" mergeRun.total = len(plans) mergeRun.done = 0 mergeRun.results = nil mergeRun.Unlock() total := len(plans) safeGo("手动合并", func() { defer releaseMergeSlot() defer func() { mergeRun.Lock() mergeRun.busy = false mergeRun.doneAt = time.Now().Unix() mergeRun.Unlock() }() stopped := false for i, plan := range plans { mergeRun.Lock() mergeRun.outName = plan.info.OutName mergeRun.done = i mergeRun.Unlock() err := runManualMerge(plan, i+1, total) // 任务卡片挂在这条链路上,无论成败都要收尾,否则进度会一直留在界面上。 // 只有真的起了任务才收尾:没起成说明占着卡片的是别的任务,别把人家的进度清掉 if err != errJobNotStarted { jobFinish(jobErrText(err)) } if err == nil { mergeRun.Lock() mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName}) mergeRun.Unlock() continue } msg := err.Error() switch err { case errNoDanmu: msg = "弹幕文件里没有弹幕" lPrintWarn("弹幕文件里没有弹幕,跳过这个任务") case errJobStopped: // 手动停止不是失败,别让界面弹「合并失败」;排在后面的任务也不再接着做 lPrintWarn("手动合并已被手动停止,剩下的任务不再继续") stopped = true default: lPrintErrf("手动合并失败:%v", err) notifyMergeFailure(guessUIDByFileName(plan.out), plan.out, err) } mergeRun.Lock() mergeRun.results = append(mergeRun.results, mergeJobResult{OutName: plan.info.OutName, Err: msg}) mergeRun.lastErr = msg mergeRun.Unlock() if stopped { break } } mergeRun.Lock() mergeRun.done = len(mergeRun.results) mergeRun.Unlock() if total > 1 { ok, failed := 0, 0 mergeRun.Lock() for _, r := range mergeRun.results { if r.ok() { ok++ } else { failed++ } } mergeRun.Unlock() lPrintf("手动合并队列结束:共 %d 个,成功 %d 个,失败 %d 个", total, ok, failed) } }) w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"count":` + strconv.Itoa(total) + `,"outName":` + strconv.Quote(plans[0].info.OutName) + `}`)) }