diff --git a/README.md b/README.md index e740cf6..d241f86 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,8 @@ go run . -admin-user owner -admin-password 'replace-with-your-own-password' | 下一集预备并发 | 1;设为 0 禁用后台预备 | | 封装缓存 | 512 MB;关闭播放后释放,可按磁盘容量调整 | +客户端直连需同时开启“优先原始 MP4 / HLS 播放”和相应站源的直连选项;只勾站源不生效。它是允许尝试,不能保证每集直连:需要服务端解密或转换、源站要求额外鉴权、HTTPS 页面访问 HTTP 视频、MP4 不支持 Range、HLS 跨域或探测失败时会使用服务器中转。当前 HLS 探测最多检查 256 个地址、8 份清单,总上限 8 秒,源站不支持 HEAD 或检查超限也可能保守回退。成功时播放计划的 `delivery` 为 `redirect`,网关返回 302;浏览器连接失败会自动回退。 + 免转码减少 CPU 开销,原样中转仍消耗 VPS 视频流量;只有客户端成功直接取源站视频才能省下这部分流量。302 会向客户端提供源站临时地址,账号权限变更后已发出的源站地址可能在到期前继续有效,因此默认关闭。 “预缓存下一集”默认开启并记住开关。新的原始播放链路只提前解析下一集地址,不下载或转码视频,切集时复用;关闭原始播放优化后沿用旧版单集有界预缓存。预备不记观看历史、不创建下载任务,关闭播放器时释放,可按需要关闭。 @@ -138,6 +140,7 @@ go run . -admin-user owner -admin-password 'replace-with-your-own-password' | 播放入口 | 视频从哪里读取 | | --- | --- | +| 下载页的“播放全集” | 读取已登记的本地合并文件,原分集已删除也可播放,保留完整文件的续播位置 | | 下载页的合集 / 分集“播放” | 下载任务已完成且 MP4 文件仍存在时,直接读取服务所在电脑、VPS 或容器上的文件,不向源站取视频 | | 下载合集中的未完成或缺失分集 | 地址可用时在线缓冲并继续对应下载;缺少有效地址时提示更新合集 | | 剧库封面 / 播放按钮 | 默认在线播放;若该剧上次从下载合集观看且仍有权限,会尝试回到合集续播。不会仅因磁盘已有下载就自动切为本地播放 | @@ -174,7 +177,11 @@ go run . -admin-user owner -admin-password 'replace-with-your-own-password' 同名不同 ID 使用不同目录。下载先写 `.part.mp4`,校验成功后生成 `001.mp4` 等最终文件,未知时长或大小不虚报进度。下载完成与源站“剧情完结”是不同状态。 -合并要求分集连续且文件存在,输出如 `剧名1-17.mp4`。原编码兼容时直接合并;编码、尺寸或音轨不一致时可使用兼容合并,保留画面比例并补边,转为 H.264 / AAC,需要额外时间与临时空间。“合并成功后删除分集文件”默认关闭,仅主动启用且输出校验成功才删除。 +合并要求分集连续且文件存在,输出如 `剧名1-17.mp4`。自动选择数量最多的兼容视频格式与音轨格式,数量相同时优先总时长更长的一组。兼容内容直接复制;时间基准或封装差异只无损换封装;音轨不同只处理少数音轨;确需统一编码或尺寸时,仅把少数分集转为多数格式,保持画面比例并补边,不再因一集不同而重转整部剧。缺音轨会补静音,已有声音不会因多数分集无声而被丢弃。下载仍保留原码流,不提前强制转码。 + +页面提交后在后台逐部合并,刷新、关页或请求断开不会中断任务,可在下载合集查看进度或“取消合并”;重复提交复用当前任务。服务重启后未完成任务明确标为中断,可重新提交。原有 curl 请求仍可等待最终结果,JSON 增加 `"async": true` 则立即返回 HTTP 202。合并后核对时长并完整解码校验,只解码、不重新编码;校验也需要 CPU 和时间。多数格式所需编码器不可用或校验失败时保留原分集,不擅自把整剧改成另一种编码。“合并成功后删除分集文件”默认关闭,仅主动启用且校验成功才删除。 + +合并成功后,下载页显示“播放全集(起止集数)”,直接读取本地合并文件,支持拖动进度和续播;只合并部分分集时按实际范围显示。原分集删除或任务记录清理后,只要合并文件与合并记录仍在即可播放,文件丢失会明确报错。兼容 MP4 直接传给浏览器,无需开启原始在线播放优化;其他编码按浏览器能力选择兼容链路。全集播放仍遵循账号的站源与下载权限。 默认保存到运行程序电脑上的 `短剧下载/`。管理员可在下载页“更改目录”,保存后重启生效;已有合集的任务、补新集和合并继续使用原路径,不自动移动视频。也可用 `-out "./我的下载"` 或 `JUKU_OUTPUT_DIR`;持续携带 `-out` 会覆盖页面保存值。本机访问可调用系统文件夹选择器,远程或无桌面环境手动填写服务器路径。 @@ -408,6 +415,10 @@ Windows PowerShell 可用 `Get-Content .\data\logs\app.log -Tail 100`。`ffmpeg. | 类型 | 功能或问题 | 当前行为 | | --- | --- | --- | | 新增 | 原始 MP4 / HLS 播放 | 兼容片源跳过 FFmpeg,支持原生 HLS 和内置 hls.js;续播、暂停、跳转与画质切换保留状态 | +| 修复 | 合并不再整剧转码 | 优先复制或换封装,视频和音轨分别按多数格式仅处理不兼容分集,合并后完整解码校验 | +| 修复 | 网页合并中途取消 | 后台执行、重复任务复用,刷新或断线不再取消 FFmpeg;支持取消和重启中断提示 | +| 新增 | 本地全集播放 | 合并文件可在下载页直接播放、拖动和续播,删除原分集或清理任务记录后仍可使用 | +| 修复 | 直连开关依赖提示 | 原始播放关闭时禁用直连选项并明确提示已保存的选择暂不生效 | | 新增 | 条件式 302 直连 | 管理员按站源开启,默认关闭;检查通过后由客户端取源站,失败退回免转码中转 | | 优化 | 解密、换封装与只转音频 | 连续处理整集,保留兼容视频;真实分片索引支持完整时间轴,视频转码作为最后回退 | | 新增 | 播放资源限制 | 分开限制会话、媒体连接、换封装、音频、视频与预备任务,并限制封装缓存 | diff --git a/internal/app/media_merge.go b/internal/app/media_merge.go index f88d1b8..e6c8601 100644 --- a/internal/app/media_merge.go +++ b/internal/app/media_merge.go @@ -12,59 +12,6 @@ import ( "time" ) -type mergeMediaInfo struct { - signature string - width int - height int - audio bool - duration time.Duration -} - -func probeMergeMedia(ctx context.Context, ffmpeg, path string) (mergeMediaInfo, error) { - probeCtx, cancel := context.WithTimeout(ctx, 20*time.Second) - defer cancel() - command := exec.CommandContext(probeCtx, ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "info", - "-protocol_whitelist", "file,pipe", "-i", path, "-map", "0:v:0", "-map", "0:a:0?", - "-c", "copy", "-t", "0", "-f", "framehash", "-hash", "sha256", "pipe:1") - output := &cappedStringWriter{limit: 64 * 1024} - diagnostics := &cappedStringWriter{limit: 64 * 1024} - command.Stdout, command.Stderr = output, diagnostics - if err := command.Run(); err != nil { - if probeCtx.Err() != nil { - return mergeMediaInfo{}, probeCtx.Err() - } - return mergeMediaInfo{}, fmt.Errorf("检测 %s 失败:%w %s", filepath.Base(path), err, truncate(diagnostics.String(), 1000)) - } - var info mergeMediaInfo - var signature []string - for _, line := range strings.Split(output.String(), "\n") { - line = strings.TrimSpace(line) - for _, prefix := range []string{"#extradata ", "#tb ", "#media_type ", "#codec_id ", "#dimensions ", "#sar ", "#sample_rate ", "#channel_layout"} { - if strings.HasPrefix(line, prefix) { - signature = append(signature, line) - break - } - } - if strings.HasPrefix(line, "#dimensions 0:") { - _, _ = fmt.Sscanf(strings.TrimSpace(strings.TrimPrefix(line, "#dimensions 0:")), "%dx%d", &info.width, &info.height) - } - if line == "#media_type 1: audio" { - info.audio = true - } - } - if match := playbackDurationPattern.FindStringSubmatch(diagnostics.String()); len(match) == 4 { - hours, _ := strconv.ParseFloat(match[1], 64) - minutes, _ := strconv.ParseFloat(match[2], 64) - seconds, _ := strconv.ParseFloat(match[3], 64) - info.duration = time.Duration((hours*3600 + minutes*60 + seconds) * float64(time.Second)) - } - if info.width <= 0 || info.height <= 0 || info.duration <= 0 { - return mergeMediaInfo{}, fmt.Errorf("%s 缺少有效的视频尺寸或时长,无法安全合并", filepath.Base(path)) - } - info.signature = strings.Join(signature, "\n") - return info, nil -} - func runMergeFFmpeg(ctx context.Context, ffmpeg string, args []string, progress func(time.Duration)) error { command := exec.CommandContext(ctx, ffmpeg, append([]string{ "-hide_banner", "-nostdin", "-nostats", "-loglevel", "error", "-progress", "pipe:1", @@ -98,29 +45,6 @@ func runMergeFFmpeg(ctx context.Context, ffmpeg string, args []string, progress return scanner.Err() } -func normalizedMergeArgs(input, output string, info mergeMediaInfo, width, height int, audio bool) []string { - args := []string{"-xerror", "-threads", "2", "-protocol_whitelist", "file,pipe", "-i", input} - if audio && !info.audio { - args = append(args, "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=48000") - } - args = append(args, "-map", "0:v:0") - if audio { - audioInput := "0:a:0" - if !info.audio { - audioInput = "1:a:0" - } - args = append(args, "-map", audioInput, "-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "2", - "-af", "aresample=48000:async=1:first_pts=0,apad", "-shortest") - } else { - args = append(args, "-an") - } - filter := fmt.Sprintf("setpts=PTS-STARTPTS,fps=30,scale=%d:%d:force_original_aspect_ratio=decrease:force_divisible_by=2,pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=1", width, height, width, height) - return append(args, "-sn", "-dn", "-map_metadata", "-1", "-vf", filter, - "-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p", - "-threads:v", "2", "-g", "60", "-sc_threshold", "0", "-video_track_timescale", "90000", - "-max_muxing_queue_size", "4096", "-t", strconv.FormatFloat(info.duration.Seconds(), 'f', 6, 64), "-f", "mp4", "-y", output) -} - func mergeMediaFiles(ctx context.Context, ffmpeg string, paths []string, output string, report func(int, string)) (string, error) { if len(paths) == 0 { return "", fmt.Errorf("没有可合并的分集") @@ -140,77 +64,98 @@ func mergeMediaFiles(ctx context.Context, ffmpeg string, paths []string, output } metadata := make([]mergeMediaInfo, 0, len(paths)) var duration time.Duration - var width, height int - var audio, normalize bool for index, path := range paths { notify(index*5/len(paths), fmt.Sprintf("检测分集编码 %d/%d", index+1, len(paths))) info, err := probeMergeMedia(ctx, ffmpeg, path) if err != nil { return "", err } - if index > 0 && info.signature != metadata[0].signature { - normalize = true - } metadata = append(metadata, info) duration += info.duration - if info.width > width { - width = info.width - } - if info.height > height { - height = info.height - } - audio = audio || info.audio } + plan := planMergeMedia(metadata) + method := plan.description() work, err := os.MkdirTemp(filepath.Dir(output), ".merge-*") if err != nil { return "", err } defer os.RemoveAll(work) inputs := append([]string(nil), paths...) - method, start := "原编码快速合并", 5 - if normalize { - method, start = "兼容合并(H.264 / AAC)", 95 - width += width % 2 - height += height % 2 - var processed time.Duration - for index, path := range paths { - detail := fmt.Sprintf("编码不一致,统一格式 %d/%d", index+1, len(paths)) - notify(5+int(processed*90/duration), detail) - temporary := filepath.Join(work, fmt.Sprintf("%06d.mp4", index+1)) - err := runMergeFFmpeg(ctx, ffmpeg, normalizedMergeArgs(path, temporary, metadata[index], width, height, audio), func(elapsed time.Duration) { + var preparedDuration, processed time.Duration + for index, step := range plan.steps { + if step.video || step.audio || step.remux { + preparedDuration += metadata[index].duration + } + } + for index, step := range plan.steps { + if !step.video && !step.audio && !step.remux { + continue + } + action := "无损换封装" + if step.video { + action = "转换此集视频" + } else if step.audio { + action = "仅调整此集音轨" + } + detail := fmt.Sprintf("%s · %s %d/%d", method, action, index+1, len(paths)) + notify(5+int(processed*60/preparedDuration), detail) + extension := ".mp4" + if plan.transport { + extension = ".ts" + } + temporary := filepath.Join(work, fmt.Sprintf("%06d%s", index+1, extension)) + args, err := plan.prepareArgs(paths[index], temporary, metadata[index], step) + if err == nil { + err = runMergeFFmpeg(ctx, ffmpeg, args, func(elapsed time.Duration) { if elapsed > metadata[index].duration { elapsed = metadata[index].duration } - notify(5+int((processed+elapsed)*90/duration), detail) + notify(5+int((processed+elapsed)*60/preparedDuration), detail) }) - if err != nil { - return "", fmt.Errorf("转换 %s 失败:%w;原分集已保留", filepath.Base(path), err) - } - processed += metadata[index].duration - inputs[index] = temporary } + if err != nil { + return "", fmt.Errorf("处理 %s 失败:%w;原分集已保留,未将整剧转码", filepath.Base(paths[index]), err) + } + processed += metadata[index].duration + inputs[index] = temporary } var manifest strings.Builder - for _, path := range inputs { + manifest.WriteString("ffconcat version 1.0\n") + for index, path := range inputs { absolute, err := filepath.Abs(path) if err != nil { return "", err } + if strings.ContainsAny(absolute, "\r\n") { + return "", fmt.Errorf("分集路径包含换行符,无法合并") + } fmt.Fprintf(&manifest, "file '%s'\n", strings.ReplaceAll(filepath.ToSlash(absolute), "'", "'\\''")) + if plan.transport { + fmt.Fprintf(&manifest, "duration %.6f\n", metadata[index].duration.Seconds()) + } } list := filepath.Join(work, "inputs.ffconcat") if err := os.WriteFile(list, []byte(manifest.String()), 0600); err != nil { return "", err } - notify(start, method) - err = runMergeFFmpeg(ctx, ffmpeg, []string{"-protocol_whitelist", "file,pipe", "-f", "concat", "-safe", "0", "-i", list, - "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn", "-c", "copy", "-movflags", "+faststart", "-y", output}, func(elapsed time.Duration) { - notify(start+int(elapsed*time.Duration(99-start)/duration), method) + notify(65, method) + args := []string{"-protocol_whitelist", "file,pipe", "-f", "concat", "-safe", "0", "-i", list, + "-map", "0:v:0", "-map", "0:a:0?", "-sn", "-dn", "-c", "copy", "-video_track_timescale", strconv.Itoa(plan.timescale), "-movflags", "+faststart"} + if plan.parameters { + if plan.video.videoCodec == "h264" { + args = append(args, "-tag:v", "avc3") + } else if plan.video.videoCodec == "hevc" { + args = append(args, "-tag:v", "hev1") + } + } + args = append(args, "-y", output) + err = runMergeFFmpeg(ctx, ffmpeg, args, func(elapsed time.Duration) { + notify(65+int(elapsed*15/duration), method) }) if err != nil { return "", err } - notify(99, "核对合并文件时长") + notify(80, "核对合并文件时长") merged, err := probeMergeMedia(ctx, ffmpeg, output) if err != nil { return "", err @@ -222,5 +167,13 @@ func mergeMediaFiles(ctx context.Context, ffmpeg string, paths []string, output if merged.duration < duration-tolerance || merged.duration > duration+tolerance { return "", fmt.Errorf("合并文件时长不符(预计 %.1f 秒,实际 %.1f 秒),原分集已保留", duration.Seconds(), merged.duration.Seconds()) } + notify(80, "校验完整视频和音轨(只解码,不转码)") + err = runMergeFFmpeg(ctx, ffmpeg, []string{"-xerror", "-err_detect", "explode", "-threads", "2", "-protocol_whitelist", "file,pipe", "-i", output, + "-map", "0:v:0", "-map", "0:a:0?", "-threads:v", "2", "-fps_mode", "passthrough", "-abort_on", "empty_output", "-f", "null", "-"}, func(elapsed time.Duration) { + notify(80+int(elapsed*19/duration), "校验完整视频和音轨(只解码,不转码)") + }) + if err != nil { + return "", fmt.Errorf("合并文件解码校验失败:%w;原分集已保留", err) + } return method, nil } diff --git a/internal/app/media_merge_plan.go b/internal/app/media_merge_plan.go new file mode 100644 index 0000000..e730118 --- /dev/null +++ b/internal/app/media_merge_plan.go @@ -0,0 +1,222 @@ +package app + +import ( + "fmt" + "strconv" + "strings" + "time" +) + +type mergeMediaPlan struct { + video, audio mergeMediaInfo + steps []mergeMediaStep + timescale int + transport bool + parameters bool + videoCount int + audioCount int + remuxCount int +} + +type mergeMediaStep struct { + video, audio, remux bool +} + +func (info mergeMediaInfo) videoKey() string { + key := fmt.Sprintf("%s|%s|%s|%dx%d|%s", info.videoCodec, info.videoProfile, info.pixelFormat, info.width, info.height, info.sar) + if info.videoCodec != "h264" && info.videoCodec != "hevc" { + key += "|" + info.videoExtra + } + return key +} + +func (info mergeMediaInfo) audioKey() string { + return fmt.Sprintf("%s|%s|%d|%s|%s", info.audioCodec, info.audioProfile, info.sampleRate, info.layout, info.audioExtra) +} + +func majorityMergeMedia(infos []mergeMediaInfo, key func(mergeMediaInfo) string, audio bool) mergeMediaInfo { + counts := make(map[string]int) + durations := make(map[string]time.Duration) + for _, info := range infos { + if audio && !info.audio { + continue + } + value := key(info) + counts[value]++ + durations[value] += info.duration + } + var chosen mergeMediaInfo + bestCount, bestDuration := 0, time.Duration(0) + for _, info := range infos { + if audio && !info.audio { + continue + } + value := key(info) + if counts[value] > bestCount || counts[value] == bestCount && durations[value] > bestDuration { + chosen, bestCount, bestDuration = info, counts[value], durations[value] + } + } + return chosen +} + +func planMergeMedia(infos []mergeMediaInfo) mergeMediaPlan { + plan := mergeMediaPlan{timescale: 90000} + plan.video = majorityMergeMedia(infos, mergeMediaInfo.videoKey, false) + var compatible []mergeMediaInfo + for _, info := range infos { + if info.videoKey() == plan.video.videoKey() { + compatible = append(compatible, info) + } + } + plan.video = majorityMergeMedia(compatible, func(info mergeMediaInfo) string { return info.videoTB }, false) + plan.audio = majorityMergeMedia(infos, mergeMediaInfo.audioKey, true) + if value, ok := strings.CutPrefix(plan.video.videoTB, "1/"); ok { + if scale, err := strconv.Atoi(value); err == nil && scale > 0 && scale <= 10000000 { + plan.timescale = scale + } + } + for _, info := range infos { + step := mergeMediaStep{video: info.videoKey() != plan.video.videoKey(), audio: plan.audio.audio && (!info.audio || info.audioKey() != plan.audio.audioKey())} + if step.video || info.videoExtra != plan.video.videoExtra { + plan.parameters = true + } + step.remux = info.videoTB != fmt.Sprintf("1/%d", plan.timescale) || plan.audio.audio && info.audioTB != plan.audio.audioTB || info.reorder + plan.steps = append(plan.steps, step) + } + plan.transport = plan.video.videoCodec == "hevc" && plan.parameters + for i := range plan.steps { + step := &plan.steps[i] + step.remux = step.remux || plan.transport + if step.video { + plan.videoCount++ + } + if step.audio { + plan.audioCount++ + } + if step.remux && !step.video && !step.audio { + plan.remuxCount++ + } + } + return plan +} + +func (plan mergeMediaPlan) description() string { + parts := []string{} + if plan.videoCount > 0 { + parts = append(parts, fmt.Sprintf("仅转视频 %d/%d 集(%s %dx%d)", plan.videoCount, len(plan.steps), strings.ToUpper(plan.video.videoCodec), plan.video.width, plan.video.height)) + } + if plan.audioCount > 0 { + parts = append(parts, fmt.Sprintf("调整音轨 %d/%d 集", plan.audioCount, len(plan.steps))) + } + if len(parts) == 0 { + if plan.remuxCount > 0 { + return "无损换封装合并(不转码)" + } + return "原编码快速合并(不转码)" + } + return strings.Join(parts, ";") +} + +func mergeVideoEncoder(info mergeMediaInfo) ([]string, error) { + profile := strings.ToLower(info.videoProfile) + var args []string + switch info.videoCodec { + case "h264": + profiles := map[string]string{"constrained baseline": "baseline", "baseline": "baseline", "main": "main", "high": "high", "high 10": "high10", "high 4:2:2": "high422", "high 4:4:4 predictive": "high444"} + value, ok := profiles[profile] + if !ok { + return nil, fmt.Errorf("暂不能将少数分集转为多数使用的 H.264 %s", info.videoProfile) + } + args = []string{"-c:v", "libx264", "-preset", "veryfast", "-crf", "18", "-profile:v", value, "-threads:v", "2"} + case "hevc": + profiles := map[string]string{"main": "main", "main 10": "main10"} + value, ok := profiles[profile] + if !ok { + return nil, fmt.Errorf("暂不能将少数分集转为多数使用的 HEVC %s", info.videoProfile) + } + args = []string{"-c:v", "libx265", "-preset", "veryfast", "-crf", "18", "-profile:v", value, "-threads:v", "2", "-x265-params", "pools=2:frame-threads=2:log-level=error"} + default: + return nil, fmt.Errorf("暂不支持将少数分集转为多数使用的 %s 编码;未对整剧转码", info.videoCodec) + } + if info.pixelFormat == "" || info.width%2 != 0 || info.height%2 != 0 { + return nil, fmt.Errorf("多数分集的像素格式或尺寸无法安全转换") + } + return append(args, "-pix_fmt", info.pixelFormat), nil +} + +func mergeAudioEncoder(info mergeMediaInfo) ([]string, error) { + encoder := info.audioCodec + switch encoder { + case "aac": + if info.audioProfile != "" && info.audioProfile != "LC" { + return nil, fmt.Errorf("暂不能将少数音轨转为多数使用的 AAC %s", info.audioProfile) + } + case "mp3": + encoder = "libmp3lame" + case "ac3", "eac3", "alac": + default: + return nil, fmt.Errorf("暂不支持将少数音轨转为多数使用的 %s", info.audioCodec) + } + if info.sampleRate <= 0 || info.layout == "" { + return nil, fmt.Errorf("多数音轨缺少采样率或声道布局,无法安全转换") + } + args := []string{"-c:a", encoder, "-ar", strconv.Itoa(info.sampleRate), "-channel_layout:a", info.layout} + if encoder == "aac" { + args = append(args, "-profile:a", "aac_low", "-b:a", "192k") + } + return args, nil +} + +func (plan mergeMediaPlan) prepareArgs(input, output string, info mergeMediaInfo, step mergeMediaStep) ([]string, error) { + args := []string{"-xerror", "-threads", "2", "-protocol_whitelist", "file,pipe", "-i", input} + if plan.audio.audio && !info.audio { + args = append(args, "-f", "lavfi", "-i", fmt.Sprintf("anullsrc=channel_layout=%s:sample_rate=%d", plan.audio.layout, plan.audio.sampleRate)) + } + args = append(args, "-map", "0:v:0", "-sn", "-dn", "-map_metadata", "-1", "-c:v", "copy") + if step.video { + encoder, err := mergeVideoEncoder(plan.video) + if err != nil { + return nil, err + } + sar := plan.video.sar + if sar == "" || sar == "0/1" { + sar = "1/1" + } + filter := "setpts=PTS-STARTPTS," + if plan.video.frameRate > 0 { + filter += "fps=" + strconv.FormatFloat(plan.video.frameRate, 'f', -1, 64) + "," + } + filter += fmt.Sprintf("scale=w='max(2,trunc(min(%d,%d*dar/(%s))/2)*2)':h='max(2,trunc(min(%d,%d*(%s)/dar)/2)*2)',pad=%d:%d:(ow-iw)/2:(oh-ih)/2,setsar=%s", plan.video.width, plan.video.height, sar, plan.video.height, plan.video.width, sar, plan.video.width, plan.video.height, sar) + args = append(args, "-vf", filter) + args = append(args, encoder...) + } + if plan.audio.audio { + stream := "0:a:0" + if !info.audio { + stream = "1:a:0" + } + args = append(args, "-map", stream) + if step.audio { + encoder, err := mergeAudioEncoder(plan.audio) + if err != nil { + return nil, err + } + args = append(args, encoder...) + args = append(args, "-af", fmt.Sprintf("aresample=%d:async=1:first_pts=0,apad", plan.audio.sampleRate)) + } else { + args = append(args, "-c:a", "copy") + } + } else { + args = append(args, "-an") + } + args = append(args, "-max_muxing_queue_size", "4096") + if step.video || step.audio { + args = append(args, "-t", strconv.FormatFloat(info.duration.Seconds(), 'f', 6, 64)) + } + if plan.transport { + args = append(args, "-bsf:v", "hevc_mp4toannexb", "-f", "mpegts") + } else { + args = append(args, "-video_track_timescale", strconv.Itoa(plan.timescale), "-f", "mp4") + } + return append(args, "-y", output), nil +} diff --git a/internal/app/media_merge_probe.go b/internal/app/media_merge_probe.go new file mode 100644 index 0000000..a45cc6d --- /dev/null +++ b/internal/app/media_merge_probe.go @@ -0,0 +1,115 @@ +package app + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "time" +) + +type mergeMediaInfo struct { + width, height int + audio bool + duration time.Duration + videoCodec, audioCodec string + videoExtra, audioExtra string + videoTB, audioTB string + videoProfile, audioProfile string + pixelFormat, sar, layout string + sampleRate int + frameRate float64 + reorder bool +} + +var mergeVideoDescription = regexp.MustCompile(`Video: [a-zA-Z0-9_]+(?: \(([^)]*)\))?`) +var mergeAudioDescription = regexp.MustCompile(`Audio: [a-zA-Z0-9_]+(?: \(([^)]*)\))?`) +var mergePixelFormat = regexp.MustCompile(`, ([a-zA-Z0-9_]+)(?:\([^)]*\))?, \d+x\d+`) +var mergeFrameRate = regexp.MustCompile(`, ([0-9.]+) (?:fps|tbr)`) +var mergeStreamIndex = regexp.MustCompile(`Stream #0:(\d+)`) + +func probeMergeMedia(ctx context.Context, ffmpeg, path string) (mergeMediaInfo, error) { + probeCtx, cancel := context.WithTimeout(ctx, 20*time.Second) + defer cancel() + command := exec.CommandContext(probeCtx, ffmpeg, "-hide_banner", "-nostdin", "-loglevel", "info", + "-protocol_whitelist", "file,pipe", "-i", path, "-map", "0:v:0", "-map", "0:a:0?", + "-c", "copy", "-t", "0", "-f", "framehash", "-hash", "sha256", "pipe:1") + output := &cappedStringWriter{limit: 64 * 1024} + diagnostics := &cappedStringWriter{limit: 64 * 1024} + command.Stdout, command.Stderr = output, diagnostics + if err := command.Run(); err != nil { + if probeCtx.Err() != nil { + return mergeMediaInfo{}, probeCtx.Err() + } + return mergeMediaInfo{}, fmt.Errorf("检测 %s 失败:%w %s", filepath.Base(path), err, truncate(diagnostics.String(), 1000)) + } + var info mergeMediaInfo + fields := make(map[string]string) + for _, line := range strings.Split(output.String(), "\n") { + if key, value, ok := strings.Cut(strings.TrimSpace(line), ":"); ok { + fields[key] = strings.TrimSpace(value) + } + if strings.HasPrefix(line, "#extradata ") { + parts := strings.Split(line, ",") + if len(parts) == 3 { + if strings.TrimSpace(parts[0]) == "#extradata 0" { + info.videoExtra = strings.TrimSpace(parts[2]) + } else if strings.TrimSpace(parts[0]) == "#extradata 1" { + info.audioExtra = strings.TrimSpace(parts[2]) + } + } + } + } + _, _ = fmt.Sscanf(fields["#dimensions 0"], "%dx%d", &info.width, &info.height) + info.audio = fields["#media_type 1"] == "audio" + info.videoCodec, info.audioCodec = fields["#codec_id 0"], fields["#codec_id 1"] + info.videoTB, info.audioTB = fields["#tb 0"], fields["#tb 1"] + info.sar = fields["#sar 0"] + if info.sar == "" || info.sar == "0/1" { + info.sar = "1/1" + } + info.sampleRate, _ = strconv.Atoi(fields["#sample_rate 1"]) + info.layout = firstNonEmpty(fields["#channel_layout_name 1"], fields["#channel_layout 1"]) + body, _, _ := strings.Cut(diagnostics.String(), "Stream mapping:") + if match := playbackDurationPattern.FindStringSubmatch(body); len(match) == 4 { + hours, _ := strconv.ParseFloat(match[1], 64) + minutes, _ := strconv.ParseFloat(match[2], 64) + seconds, _ := strconv.ParseFloat(match[3], 64) + info.duration = time.Duration((hours*3600 + minutes*60 + seconds) * float64(time.Second)) + } + streams, video, audio := 0, false, false + for _, line := range strings.Split(body, "\n") { + index := mergeStreamIndex.FindStringSubmatch(line) + if len(index) != 2 { + continue + } + streams++ + if strings.Contains(line, "Video:") && !video { + video = true + info.reorder = info.reorder || index[1] != "0" + if match := mergeVideoDescription.FindStringSubmatch(line); len(match) == 2 { + info.videoProfile = match[1] + } + if match := mergePixelFormat.FindStringSubmatch(line); len(match) == 2 { + info.pixelFormat = match[1] + } + if match := mergeFrameRate.FindStringSubmatch(line); len(match) == 2 { + info.frameRate, _ = strconv.ParseFloat(match[1], 64) + } + } else if strings.Contains(line, "Audio:") && !audio { + audio = true + info.reorder = info.reorder || index[1] != "1" + if match := mergeAudioDescription.FindStringSubmatch(line); len(match) == 2 { + info.audioProfile = match[1] + } + } + } + info.reorder = info.reorder || streams > 2 + if info.width <= 0 || info.height <= 0 || info.duration <= 0 || info.videoCodec == "" { + return mergeMediaInfo{}, fmt.Errorf("%s 缺少有效的视频编码、尺寸或时长,无法安全合并", filepath.Base(path)) + } + return info, nil +} diff --git a/internal/app/media_merge_test.go b/internal/app/media_merge_test.go new file mode 100644 index 0000000..d8fbe63 --- /dev/null +++ b/internal/app/media_merge_test.go @@ -0,0 +1,149 @@ +package app + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func mergeFixtureCommand(t *testing.T, ffmpeg string, args ...string) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, ffmpeg, append([]string{"-hide_banner", "-nostdin", "-loglevel", "error", "-y"}, args...)...) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("synthetic merge fixture: %v\n%s", err, output) + } + return string(output) +} + +func mergeFixtureFrames(t *testing.T, ffmpeg, path string) []string { + t.Helper() + output := mergeFixtureCommand(t, ffmpeg, "-i", path, "-map", "0:v:0", "-fps_mode", "passthrough", "-f", "framemd5", "-") + var frames []string + for _, line := range strings.Split(output, "\n") { + if line == "" || strings.HasPrefix(line, "#") { + continue + } + parts := strings.Split(line, ",") + frames = append(frames, strings.TrimSpace(parts[len(parts)-1])) + } + return frames +} + +func TestMergePreservesMajorityAndOnlyConvertsIncompatibleStreams(t *testing.T) { + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Skip("local FFmpeg unavailable") + } + work := t.TempDir() + base := filepath.Join(work, "majority.mp4") + mergeFixtureCommand(t, ffmpeg, "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=2", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=2", "-c:v", "libx264", "-preset", "veryfast", "-threads:v", "2", "-pix_fmt", "yuv420p", "-c:a", "aac", "-ac", "2", base) + variants := map[string][]string{ + "timebase": {"-c", "copy", "-video_track_timescale", "90000"}, + "audio": {"-c:v", "copy", "-c:a", "aac", "-ar", "44100"}, + "small": {"-c:v", "libx264", "-preset", "veryfast", "-threads:v", "2", "-vf", "scale=240:136", "-c:a", "copy"}, + "parameters": {"-c:v", "libx264", "-preset", "fast", "-threads:v", "2", "-c:a", "copy"}, + "silent": {"-c:v", "copy", "-an"}, + "reordered": {"-map", "0:a:0", "-map", "0:v:0", "-c", "copy"}, + } + for name, args := range variants { + mergeFixtureCommand(t, ffmpeg, append(append([]string{"-i", base}, args...), filepath.Join(work, name+".mp4"))...) + } + baseFrames := mergeFixtureFrames(t, ffmpeg, base) + for _, test := range []struct { + name string + video, audio int + }{ + {"timebase", 0, 0}, {"parameters", 0, 0}, {"audio", 0, 1}, {"small", 1, 0}, {"silent", 0, 1}, {"reordered", 0, 0}, + } { + t.Run(test.name, func(t *testing.T) { + minority := filepath.Join(work, test.name+".mp4") + paths := []string{minority, base, base} + var infos []mergeMediaInfo + for _, path := range paths { + info, err := probeMergeMedia(context.Background(), ffmpeg, path) + if err != nil { + t.Fatal(err) + } + infos = append(infos, info) + } + plan := planMergeMedia(infos) + if plan.videoCount != test.video || plan.audioCount != test.audio || plan.video.width != 320 || plan.video.height != 180 { + t.Fatalf("wrong majority/stream selection: %+v", plan) + } + output := filepath.Join(t.TempDir(), "merged.mp4") + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + method, err := mergeMediaFiles(ctx, ffmpeg, paths, output, nil) + if err != nil { + t.Fatal(err) + } + frames := mergeFixtureFrames(t, ffmpeg, output) + if len(frames) != 150 || !reflect.DeepEqual(frames[50:100], baseFrames) || !reflect.DeepEqual(frames[100:], baseFrames) { + t.Fatalf("majority frames changed or disappeared: frames=%d method=%s", len(frames), method) + } + if test.video == 0 && !reflect.DeepEqual(frames[:50], mergeFixtureFrames(t, ffmpeg, minority)) { + t.Fatal("copied minority video changed") + } + merged, err := probeMergeMedia(ctx, ffmpeg, output) + if err != nil || !merged.audio || merged.sampleRate != 48000 || merged.width != 320 || merged.height != 180 { + t.Fatalf("output format: %+v %v", merged, err) + } + }) + } + t.Run("retain-real-audio-when-majority-silent", func(t *testing.T) { + silent := filepath.Join(work, "silent.mp4") + output := filepath.Join(t.TempDir(), "merged.mp4") + method, err := mergeMediaFiles(context.Background(), ffmpeg, []string{silent, base, silent}, output, nil) + if err != nil || !strings.Contains(method, "调整音轨 2/3") || strings.Contains(method, "转视频") { + t.Fatal("real audio was lost or silent majority video encoded", method, err) + } + info, err := probeMergeMedia(context.Background(), ffmpeg, output) + if err != nil || !info.audio { + t.Fatal(info, err) + } + }) + t.Run("cancel-preserves-inputs", func(t *testing.T) { + before, _ := os.ReadFile(base) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := mergeMediaFiles(ctx, ffmpeg, []string{base, base}, filepath.Join(t.TempDir(), "out.mp4"), nil); err == nil { + t.Fatal("canceled merge succeeded") + } + after, _ := os.ReadFile(base) + if !reflect.DeepEqual(before, after) { + t.Fatal("merge altered original episode") + } + }) + t.Run("hevc-parameter-sets", func(t *testing.T) { + encoders := mergeFixtureCommand(t, ffmpeg, "-encoders") + if !strings.Contains(encoders, "libx265") { + t.Skip("HEVC encoder unavailable") + } + hevc := filepath.Join(work, "hevc.mp4") + mergeFixtureCommand(t, ffmpeg, "-i", base, "-c:v", "libx265", "-preset", "ultrafast", "-x265-params", "pools=1:frame-threads=1:log-level=error", "-c:a", "copy", hevc) + for _, majority := range []string{base, hevc} { + minority := hevc + if majority == hevc { + minority = filepath.Join(work, "small.mp4") + } + output := filepath.Join(t.TempDir(), "merged.mp4") + method, err := mergeMediaFiles(context.Background(), ffmpeg, []string{minority, majority, majority}, output, nil) + if err != nil || !strings.Contains(method, "仅转视频 1/3") { + t.Fatal(method, err) + } + frames := mergeFixtureFrames(t, ffmpeg, output) + want := mergeFixtureFrames(t, ffmpeg, majority) + if len(frames) != 150 || !reflect.DeepEqual(frames[50:100], want) || !reflect.DeepEqual(frames[100:], want) { + t.Fatalf("parameter changes corrupted copied majority frames: %d", len(frames)) + } + } + }) +} diff --git a/internal/app/ui_merge_jobs.go b/internal/app/ui_merge_jobs.go new file mode 100644 index 0000000..133d716 --- /dev/null +++ b/internal/app/ui_merge_jobs.go @@ -0,0 +1,141 @@ +package app + +import ( + "context" + "fmt" + "net/http" + "time" +) + +type uiMergeJob struct { + dramaID string + tasks []*UITask + remove bool + ctx context.Context + cancel context.CancelFunc + done chan struct{} + result uiMergeResult + running bool +} + +func (a *UIApp) enqueueMergesLocked(ids []string, deleteEpisodes bool) ([]*uiMergeJob, error) { + if a.mergeStopped { + return nil, fmt.Errorf("服务正在停止,无法提交合并") + } + if len(a.mergeJobs)+len(ids) > 256 { + return nil, fmt.Errorf("合并队列已满,请等待当前任务完成后再提交") + } + if a.mergeJobs == nil { + a.mergeJobs = make(map[string]*uiMergeJob) + } + if a.merges == nil { + a.merges = make(map[string]*UIMergeState) + } + byDrama := make(map[string][]*UITask) + for _, taskID := range a.taskOrder { + if task := a.tasks[taskID]; task != nil && task.Status == uiStatusSuccess && !task.RemoveRequested { + byDrama[task.DramaID] = append(byDrama[task.DramaID], cloneUITask(task)) + } + } + previous := cloneMergeStates(a.merges) + var jobs, added []*uiMergeJob + for _, id := range ids { + job := a.mergeJobs[id] + if job == nil { + ctx, cancel := context.WithCancel(context.Background()) + job = &uiMergeJob{dramaID: id, tasks: byDrama[id], remove: deleteEpisodes, ctx: ctx, cancel: cancel, done: make(chan struct{})} + a.mergeJobs[id] = job + added = append(added, job) + title := "" + if len(job.tasks) > 0 { + title = job.tasks[0].DramaTitle + } + a.merges[id] = &UIMergeState{DramaID: id, DramaTitle: title, Status: "queued", Detail: "已提交后台合并,可关闭或刷新页面", DeleteEpisodes: deleteEpisodes, UpdatedAt: time.Now()} + } + jobs = append(jobs, job) + } + if err := a.saveStateLocked(); err != nil { + for _, job := range added { + delete(a.mergeJobs, job.dramaID) + job.cancel() + } + a.merges = previous + return nil, fmt.Errorf("保存合并任务失败:%w", err) + } + if len(added) > 0 { + go a.runMergeJobs(added) + } + return jobs, nil +} + +func (a *UIApp) runMergeJobs(jobs []*uiMergeJob) { + a.mergeMu.Lock() + defer a.mergeMu.Unlock() + for _, job := range jobs { + a.mu.Lock() + if a.mergeJobs[job.dramaID] != job { + a.mu.Unlock() + continue + } + job.running = true + a.mu.Unlock() + if job.ctx.Err() == nil { + job.result = a.mergeDrama(job.ctx, job.dramaID, job.tasks, job.remove) + } else { + job.result = uiMergeResult{DramaID: job.dramaID, Error: "合并已取消,原分集已保留"} + a.setMergeState(job.result, "failed", 0, false, job.remove) + } + job.cancel() + a.mu.Lock() + delete(a.mergeJobs, job.dramaID) + close(job.done) + a.mu.Unlock() + } +} + +func (a *UIApp) stopMergeJobs() { + a.mu.Lock() + a.mergeStopped = true + var jobs []*uiMergeJob + for _, job := range a.mergeJobs { + job.cancel() + jobs = append(jobs, job) + } + a.mu.Unlock() + for _, job := range jobs { + <-job.done + } +} + +func (a *UIApp) handleMergeCancel(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) + return + } + if !requireDownload(w, r) { + return + } + ids, ok := readDramaIDsRequest(w, r) + if !ok || !a.requireDramaSources(w, r, ids) { + return + } + a.mu.Lock() + defer a.mu.Unlock() + canceled := 0 + for _, id := range ids { + if job := a.mergeJobs[id]; job != nil { + job.cancel() + if !job.running { + job.result = uiMergeResult{DramaID: id, Error: "合并已取消,原分集已保留"} + if state := a.merges[id]; state != nil { + state.Status, state.Detail, state.Error = "failed", "", job.result.Error + } + delete(a.mergeJobs, id) + close(job.done) + } + canceled++ + } + } + _ = a.saveStateLocked() + writeJSON(w, http.StatusOK, map[string]int{"canceled": canceled}) +} diff --git a/internal/app/ui_merge_jobs_test.go b/internal/app/ui_merge_jobs_test.go new file mode 100644 index 0000000..65cdbf7 --- /dev/null +++ b/internal/app/ui_merge_jobs_test.go @@ -0,0 +1,110 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestMergeJobsSurviveRequestCancellationAndDeduplicate(t *testing.T) { + for _, async := range []bool{false, true} { + t.Run(map[bool]string{false: "curl-compatible", true: "async-page"}[async], func(t *testing.T) { + app, session := nativePlaybackFixture(t, "2", "320x180") + app.taskOrder = session.downloadIDs + app.statePath = filepath.Join(app.cfg.dataDirectory(), "ui-state.json") + app.downloader.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: app.downloader.cfg.FFmpeg}} + app.cfg.SkipBytes = 1 + app.mergeMu.Lock() + var once sync.Once + unlock := func() { once.Do(app.mergeMu.Unlock) } + defer func() { unlock(); app.stopMergeJobs() }() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + body, _ := json.Marshal(map[string]any{"dramaIds": []string{historyFixtureDramaID}, "async": async}) + request := httptest.NewRequest(http.MethodPost, "/api/ui/merge", bytes.NewReader(body)).WithContext(ctx) + writer := httptest.NewRecorder() + returned := make(chan struct{}) + go func() { app.handleMerge(writer, request); close(returned) }() + var job *uiMergeJob + deadline := time.Now().Add(5 * time.Second) + for job == nil && time.Now().Before(deadline) { + app.mu.Lock() + job = app.mergeJobs[historyFixtureDramaID] + app.mu.Unlock() + time.Sleep(time.Millisecond) + } + if job == nil { + t.Fatal("merge was not queued") + } + cancel() + select { + case <-returned: + case <-time.After(time.Second): + t.Fatal("canceled request remained tied to the merge") + } + if async && writer.Code != http.StatusAccepted || job.ctx.Err() != nil { + t.Fatal("request canceled its background merge", writer.Code, job.ctx.Err()) + } + app.mu.Lock() + duplicate, err := app.enqueueMergesLocked([]string{historyFixtureDramaID}, true) + app.mu.Unlock() + if err != nil || len(duplicate) != 1 || duplicate[0] != job || job.remove { + t.Fatal("duplicate submission changed or duplicated the original merge", err) + } + unlock() + select { + case <-job.done: + case <-time.After(30 * time.Second): + t.Fatal("detached merge did not finish") + } + if !job.result.OK { + t.Fatal("background merge failed", job.result.Error) + } + if _, err := os.Stat(job.result.OutputPath); err != nil { + t.Fatal("background output missing", err) + } + if _, err := os.Stat(session.tasks[0].OutPath); err != nil { + t.Fatal("duplicate submission deleted original episodes", err) + } + }) + } +} + +func TestMergeQueueCancelAndRestartAreExplicit(t *testing.T) { + app := viewerTestApp(t) + app.statePath = filepath.Join(app.cfg.dataDirectory(), "ui-state.json") + app.mergeMu.Lock() + var once sync.Once + unlock := func() { once.Do(app.mergeMu.Unlock) } + defer func() { unlock(); app.stopMergeJobs() }() + app.mu.Lock() + jobs, err := app.enqueueMergesLocked([]string{historyFixtureDramaID}, false) + app.mu.Unlock() + if err != nil { + t.Fatal(err) + } + restored := &UIApp{cfg: app.cfg, statePath: app.statePath, tasks: map[string]*UITask{}} + restored.loadState() + if state := restored.merges[historyFixtureDramaID]; state == nil || state.Status != "failed" || state.Error == "" { + t.Fatal("restart left an interrupted merge permanently queued", state) + } + writer := historyJSONRequest(t, app, app.handleMergeCancel, "/api/ui/merge/cancel", map[string]any{"dramaIds": []string{historyFixtureDramaID}}) + if writer.Code != http.StatusOK { + t.Fatal(writer.Code, writer.Body.String()) + } + select { + case <-jobs[0].done: + case <-time.After(time.Second): + t.Fatal("queued cancellation waited for an unrelated active merge") + } + if jobs[0].result.OK || app.merges[historyFixtureDramaID].Status != "failed" { + t.Fatal("canceled merge reported success") + } +} diff --git a/internal/app/ui_playback.go b/internal/app/ui_playback.go index b1f3df9..89cc479 100644 --- a/internal/app/ui_playback.go +++ b/internal/app/ui_playback.go @@ -51,6 +51,7 @@ type playbackSession struct { } type playbackEpisode struct { + Merged bool `json:"merged,omitempty"` VIP bool `json:"vip,omitempty"` Index int `json:"index"` Episode string `json:"episode"` @@ -190,15 +191,22 @@ func (app *UIApp) handlePlaybackOpen(writer http.ResponseWriter, request *http.R var collectionErr error app.mu.Lock() if input.FromHistory && previous.Mode == "collection" && downloadAllowed(request.Context()) { - candidate := app.tasks[previous.TaskID] - if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID { - input.TaskID = candidate.ID - } else { - for _, taskID := range app.taskOrder { - candidate = app.tasks[taskID] - if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID { - input.TaskID = candidate.ID - break + if strings.HasPrefix(previous.TaskID, mergedPlaybackPrefix) && previous.TaskID == mergedPlaybackPrefix+previous.DramaID { + if _, _, err := app.mergedPlaybackTaskLocked(previous.TaskID); err == nil { + input.TaskID = previous.TaskID + } + } + if input.TaskID == "" { + candidate := app.tasks[previous.TaskID] + if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID { + input.TaskID = candidate.ID + } else { + for _, taskID := range app.taskOrder { + candidate = app.tasks[taskID] + if isPlayableDownloadTask(candidate) && candidate.DramaID == previous.DramaID { + input.TaskID = candidate.ID + break + } } } } @@ -310,6 +318,10 @@ func (app *UIApp) handlePlaybackOpen(writer http.ResponseWriter, request *http.R _, _, episode.Danmaku = hongguoPlaybackIDs(task) if len(downloadIDs) > 0 { episode.TaskID = downloadIDs[index] + episode.Merged = strings.HasPrefix(episode.TaskID, mergedPlaybackPrefix) + if episode.Merged { + episode.Danmaku = false + } } episodes = append(episodes, episode) } diff --git a/internal/app/ui_playback_collection.go b/internal/app/ui_playback_collection.go index 76775f9..d0aec0b 100644 --- a/internal/app/ui_playback_collection.go +++ b/internal/app/ui_playback_collection.go @@ -26,6 +26,13 @@ func hasCollectionPlaybackMedia(task Task) bool { } func (app *UIApp) collectionPlaybackTasksLocked(selectedID string) (string, []Task, []string, int, error) { + if strings.HasPrefix(selectedID, mergedPlaybackPrefix) { + task, _, err := app.mergedPlaybackTaskLocked(selectedID) + if err != nil { + return "", nil, nil, 0, err + } + return task.DramaTitle, []Task{task}, []string{selectedID}, 1, nil + } selected := app.tasks[selectedID] if !isPlayableDownloadTask(selected) { return "", nil, nil, 0, errors.New("此分集不存在或尚未完成章节解析,请更新合集后再播放") @@ -89,6 +96,9 @@ func completedPlaybackPath(task *UITask) string { func (app *UIApp) playbackCollectionTask(id string) (Task, string, error) { app.mu.Lock() defer app.mu.Unlock() + if strings.HasPrefix(id, mergedPlaybackPrefix) { + return app.mergedPlaybackTaskLocked(id) + } task := app.tasks[id] if !isPlayableDownloadTask(task) { return Task{}, "", errors.New("分集任务已被清理或正在移除,请重新打开合集") @@ -105,6 +115,12 @@ type collectionPlaybackPreparation struct { func (app *UIApp) prepareCollectionDownload(ctx context.Context, id string) (collectionPlaybackPreparation, error) { app.mu.Lock() defer app.mu.Unlock() + if strings.HasPrefix(id, mergedPlaybackPrefix) { + if _, _, err := app.mergedPlaybackTaskLocked(id); err != nil { + return collectionPlaybackPreparation{}, err + } + return collectionPlaybackPreparation{Source: "merged", DownloadStatus: uiStatusSuccess}, ctx.Err() + } task := app.tasks[id] if !isPlayableDownloadTask(task) { return collectionPlaybackPreparation{}, errors.New("分集任务已被清理或正在移除,请重新打开合集") diff --git a/internal/app/ui_playback_merged.go b/internal/app/ui_playback_merged.go new file mode 100644 index 0000000..593750a --- /dev/null +++ b/internal/app/ui_playback_merged.go @@ -0,0 +1,30 @@ +package app + +import ( + "encoding/json" + "errors" + "fmt" + "strings" +) + +const mergedPlaybackPrefix = "merged:" + +func (app *UIApp) mergedPlaybackTaskLocked(id string) (Task, string, error) { + dramaID, ok := strings.CutPrefix(id, mergedPlaybackPrefix) + if !ok || dramaID == "" { + return Task{}, "", errors.New("合并播放记录无效") + } + state := app.merges[dramaID] + if state == nil || state.Status != "success" { + return Task{}, "", errors.New("本剧尚无合并成功的文件") + } + path := completedPlaybackPath(&UITask{Status: uiStatusSuccess, Path: state.OutputPath}) + if path == "" { + return Task{}, "", errors.New("本地合并文件不存在或已移动,请重新合并") + } + label := fmt.Sprintf("全集(第%d–%d集)", state.StartEpisode, state.EndEpisode) + episode, _ := json.Marshal(label) + task := Task{DramaID: dramaID, DramaTitle: state.DramaTitle, Index: 1, Total: 1, OutPath: path, + Chapter: Chapter{ID: fmt.Sprintf("%s:%d-%d", id, state.StartEpisode, state.EndEpisode), Source: sourceFromDramaID(dramaID), Title: label, CurrentEpisode: episode}} + return task, path, nil +} diff --git a/internal/app/ui_playback_merged_browser_test.go b/internal/app/ui_playback_merged_browser_test.go new file mode 100644 index 0000000..d97068a --- /dev/null +++ b/internal/app/ui_playback_merged_browser_test.go @@ -0,0 +1,67 @@ +package app + +import ( + "encoding/json" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "sync" + "testing" + "time" +) + +func TestMergedPlaybackBrowserFixture(t *testing.T) { + root := os.Getenv("JUKU_MERGED_BROWSER_ROOT") + if root == "" { + t.Skip("isolated browser fixture is opt-in") + } + if err := os.MkdirAll(root, 0700); err != nil { + t.Fatal(err) + } + ffmpeg, err := exec.LookPath("ffmpeg") + if err != nil { + t.Fatal(err) + } + video := filepath.Join(root, "synthetic-full.mp4") + mergeFixtureCommand(t, ffmpeg, "-f", "lavfi", "-i", "testsrc2=size=320x180:rate=25:duration=12", "-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=12", "-c:v", "libx264", "-preset", "veryfast", "-threads:v", "2", "-c:a", "aac", "-ac", "2", "-movflags", "+faststart", video) + app := viewerTestApp(t) + app.loadedAt, app.libraryAttempted = time.Now(), true + app.statePath = filepath.Join(app.cfg.dataDirectory(), "ui-state.json") + app.tasks = map[string]*UITask{} + app.cond = sync.NewCond(&app.mu) + app.merges = map[string]*UIMergeState{historyFixtureDramaID: {DramaID: historyFixtureDramaID, DramaTitle: "本地合成全集", Status: "success", Progress: 100, StartEpisode: 1, EndEpisode: 3, Merged: 3, OutputPath: video}} + app.downloader.ffmpegInstaller = &ffmpegInstaller{state: ffmpegInstallState{Status: "ready", Path: ffmpeg}} + app.cfg.SkipBytes = 1 + for index, id := range []string{"synthetic-one", "synthetic-two"} { + task := Task{DramaID: "hongguo:7000000000000000005", DramaTitle: "待合并的本地分集", Index: index + 1, Total: 2, OutPath: video, Chapter: Chapter{Source: sourceHongguo, CurrentEpisode: rawEpisode(index + 1)}} + app.tasks[id] = &UITask{ID: id, DramaID: task.DramaID, DramaTitle: task.DramaTitle, Path: video, Status: uiStatusSuccess, Source: task} + app.taskOrder = append(app.taskOrder, id) + } + app.cfg.adminUsername, app.cfg.adminPassword, app.cfg.adminPasswordExplicit = "admin", accountFixturePassword, true + if err := app.prepareBrowserViewers(); err != nil { + t.Fatal(err) + } + t.Cleanup(app.embySyncer().stop) + t.Cleanup(app.stopMergeJobs) + server := httptest.NewServer(app.routes()) + defer server.Close() + body, _ := json.Marshal(map[string]string{"url": server.URL}) + if err := os.WriteFile(filepath.Join(root, "ready.json"), body, 0600); err != nil { + t.Fatal(err) + } + deadline := time.NewTimer(5 * time.Minute) + defer deadline.Stop() + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if _, err := os.Stat(filepath.Join(root, "stop")); err == nil { + return + } + case <-deadline.C: + t.Fatal("merged playback browser fixture timed out") + } + } +} diff --git a/internal/app/ui_playback_merged_test.go b/internal/app/ui_playback_merged_test.go new file mode 100644 index 0000000..52aeb07 --- /dev/null +++ b/internal/app/ui_playback_merged_test.go @@ -0,0 +1,123 @@ +package app + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestMergedPlaybackLocalRangeResumeWithoutEpisodes(t *testing.T) { + app, fixture := nativePlaybackFixture(t, "6", "320x180") + path := fixture.tasks[0].OutPath + dramaID := fixture.tasks[0].DramaID + id := mergedPlaybackPrefix + dramaID + app.merges = map[string]*UIMergeState{dramaID: {DramaID: dramaID, DramaTitle: "合成全集", Status: "success", StartEpisode: 1, EndEpisode: 3, Merged: 3, OutputPath: path}} + app.taskOrder = append([]string(nil), fixture.downloadIDs...) + for _, taskID := range fixture.downloadIDs { + app.removeTaskLocked(taskID) + } + if len(app.tasks) != 0 || app.merges[dramaID] == nil { + t.Fatal("clearing the final episode task removed the merged file record") + } + app.statePath = filepath.Join(app.cfg.dataDirectory(), "ui-state.json") + app.downloader.cfg.FFmpeg = filepath.Join(t.TempDir(), "ffmpeg-must-not-run") + app.mediaResources().settings.Enabled = false + app.mu.Lock() + err := app.saveStateLocked() + app.mu.Unlock() + if err != nil { + t.Fatal(err) + } + restored := &UIApp{cfg: app.cfg, statePath: app.statePath, tasks: map[string]*UITask{}} + restored.loadState() + if _, local, err := restored.mergedPlaybackTaskLocked(id); err != nil || local != path { + t.Fatal("restart lost merged file without episode tasks", local, err) + } + open := func(body any) map[string]json.RawMessage { + t.Helper() + result := historyJSONRequest(t, app, app.handlePlaybackOpen, "/api/ui/playback/open", body) + if result.Code != http.StatusOK { + t.Fatal(result.Code, result.Body.String()) + } + var response map[string]json.RawMessage + if err := json.Unmarshal(result.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response + } + opened := open(map[string]any{"taskId": id, "resume": true}) + var sessionID string + _ = json.Unmarshal(opened["session"], &sessionID) + defer app.closePlaybacks() + var episodes []playbackEpisode + _ = json.Unmarshal(opened["episodes"], &episodes) + if len(episodes) != 1 || !episodes[0].Merged || episodes[0].TaskID != id || episodes[0].Danmaku { + t.Fatal("merged file did not appear as a standalone full-length selection", string(opened["episodes"])) + } + preparation := historyJSONRequest(t, app, app.handlePlaybackPrepare, "/api/ui/playback/prepare", map[string]any{"session": sessionID, "episode": 1}) + if preparation.Code != http.StatusOK || !strings.Contains(preparation.Body.String(), `"source":"merged"`) || len(app.tasks) != 0 { + t.Fatal("merged playback attempted episode downloads", preparation.Code, preparation.Body.String()) + } + result := historyJSONRequest(t, app, app.handlePlaybackPlan, "/api/ui/playback/plan", map[string]any{"session": sessionID, "episode": 1, "version": 1, "start": 2.0, + "client": playbackClientCapabilities{MP4: true, Video: []string{"h264"}, Audio: []string{"aac"}}}) + var plan playbackMediaPlan + if result.Code != http.StatusOK || json.Unmarshal(result.Body.Bytes(), &plan) != nil || plan.Delivery != "local" || plan.Player != "mp4" || plan.Processing != "original" { + t.Fatal("local merged playback depended on the online optimization flag or FFmpeg", result.Code, result.Body.String()) + } + body, _ := os.ReadFile(path) + for _, test := range []struct { + method, span string + code, length int + }{{"GET", "bytes=0-15", 206, 16}, {"GET", "bytes=-16", 206, 16}, {"HEAD", "", 200, 0}} { + request := httptest.NewRequest(test.method, "http://localhost"+plan.URL, nil) + request.Header.Set("Range", test.span) + writer := httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, viewerFixtureRequest(app, request)) + if writer.Code != test.code || writer.Body.Len() != test.length || test.span == "bytes=-16" && !bytes.Equal(writer.Body.Bytes(), body[len(body)-16:]) { + t.Fatal("merged file did not serve local byte ranges", writer.Code, writer.Body.Len()) + } + } + session := app.playbacks[sessionID] + if _, saved, err := app.recordPlaybackProgress(fixtureViewer(app), sessionID, playbackHistoryProgress{Run: session.run, Sequence: 1, Episode: 1, Position: 2.5, Duration: 6}); err != nil || !saved { + t.Fatal("merged progress was not saved", saved, err) + } + app.closePlayback(sessionID) + resumed := open(map[string]any{"dramaId": dramaID, "resume": true, "fromHistory": true}) + if string(resumed["initialPosition"]) != "2.5" || !bytes.Contains(resumed["episodes"], []byte(`"merged":true`)) { + t.Fatal("merged history returned to online episodes or lost the full-file timestamp", string(resumed["initialPosition"]), string(resumed["episodes"])) + } + for _, scope := range []accountRecord{{OnlineOnly: true}, {Sources: []string{"no-access"}}} { + body, _ := json.Marshal(map[string]string{"taskId": id}) + request := viewerFixtureRequest(app, httptest.NewRequest(http.MethodPost, "/api/ui/playback/open", bytes.NewReader(body))) + request.Header.Set("Content-Type", "application/json") + request = request.WithContext(withSourceScope(request.Context(), scope)) + writer := httptest.NewRecorder() + app.handlePlaybackOpen(writer, request) + if writer.Code != http.StatusForbidden { + t.Fatal("merged playback bypassed source/download permissions", scope, writer.Code, writer.Body.String()) + } + } + other := app.browserViewers().acquire(viewerID("different-merged-viewer")) + defer other.release() + request := httptest.NewRequest(http.MethodGet, "http://localhost"+plan.URL, nil) + writer := httptest.NewRecorder() + app.handlePlaybackMediaAsset(writer, request.WithContext(context.WithValue(request.Context(), viewerContextKey{}, other))) + if writer.Code != http.StatusGone { + t.Fatal("merged playback leaked to another viewer", writer.Code) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, _, err := app.playbackCollectionTask(id); err == nil { + t.Fatal("missing merged file fell back to upstream video") + } + if _, err := app.prepareCollectionDownload(context.Background(), id); err == nil || len(app.tasks) != 0 { + t.Fatal("missing merged file created download tasks", err) + } +} diff --git a/internal/app/ui_playback_plan.go b/internal/app/ui_playback_plan.go index ad2a84c..b413fca 100644 --- a/internal/app/ui_playback_plan.go +++ b/internal/app/ui_playback_plan.go @@ -167,8 +167,14 @@ func (app *UIApp) handlePlaybackPlan(writer http.ResponseWriter, request *http.R return } if !app.mediaResources().snapshot().Enabled { - writeJSON(writer, http.StatusOK, playbackMediaPlan{Player: "legacy", Reason: "optimization_disabled"}) - return + app.playbackMu.Lock() + session := app.playbacks[input.Session] + merged := session != nil && input.Episode <= len(session.downloadIDs) && strings.HasPrefix(session.downloadIDs[input.Episode-1], mergedPlaybackPrefix) + app.playbackMu.Unlock() + if !merged { + writeJSON(writer, http.StatusOK, playbackMediaPlan{Player: "legacy", Reason: "optimization_disabled"}) + return + } } run, status, err := app.beginPlayback(context.WithoutCancel(request.Context()), input.Session, input.Episode, input.Start, input.Quality, input.Version, false) if err != nil { diff --git a/internal/app/ui_server.go b/internal/app/ui_server.go index 4ccf02f..285c6d7 100644 --- a/internal/app/ui_server.go +++ b/internal/app/ui_server.go @@ -67,6 +67,8 @@ type UIApp struct { address string runningCancels map[string]context.CancelFunc mergeMu sync.Mutex + mergeJobs map[string]*uiMergeJob + mergeStopped bool parsingInFlight map[string]bool parsingCancels map[string]context.CancelFunc closed bool @@ -103,6 +105,7 @@ type uiState struct { } type UIMergeState struct { + PlaybackTaskID string `json:"playbackTaskId,omitempty"` DramaID string `json:"dramaId"` DramaTitle string `json:"dramaTitle,omitempty"` Status string `json:"status"` @@ -240,6 +243,7 @@ func (a *UIApp) ListenAndServe(addr string) error { a.mu.Unlock() defer a.closePlaybacks() + defer a.stopMergeJobs() server := &http.Server{Addr: addr, Handler: a.routes(), ReadHeaderTimeout: 10 * time.Second} return server.ListenAndServe() @@ -277,6 +281,7 @@ func (a *UIApp) routes() http.Handler { mux.HandleFunc("/api/ui/tasks/resume", a.handleResume) mux.HandleFunc("/api/ui/tasks/clear", a.handleClear) mux.HandleFunc("/api/ui/merge", a.handleMerge) + mux.HandleFunc("/api/ui/merge/cancel", a.handleMergeCancel) mux.HandleFunc("/api/ui/ffmpeg", a.handleFFmpeg) mux.HandleFunc("/api/ui/config", a.handleConfig) mux.HandleFunc("/api/ui/network/check", a.handleNetworkCheck) @@ -529,6 +534,12 @@ func (a *UIApp) loadState() { } seen := map[string]bool{} changed := false + for _, state := range a.merges { + if state != nil && (state.Status == "running" || state.Status == "queued") { + state.Status, state.Detail, state.Error = "failed", "", "服务已重启,合并已中断;原分集已保留,可重新合并" + changed = true + } + } for _, task := range state.Tasks { if task == nil || task.RemoveRequested { changed = true @@ -1372,6 +1383,11 @@ func (a *UIApp) handleTasks(w http.ResponseWriter, r *http.Request) { for id := range merges { if !dramaAllowed(r.Context(), id, "") { delete(merges, id) + continue + } + merges[id].PlaybackTaskID = "" + if _, _, err := a.mergedPlaybackTaskLocked(mergedPlaybackPrefix + id); err == nil { + merges[id].PlaybackTaskID = mergedPlaybackPrefix + id } } a.mu.Unlock() @@ -1548,37 +1564,33 @@ func (a *UIApp) handleMerge(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"}) return } - ids, deleteEpisodes, ok := readMergeRequest(w, r) - if !ok { + ids, deleteEpisodes, async, ok := readMergeRequest(w, r) + if !ok || !requireDownload(w, r) || !a.requireDramaSources(w, r, ids) { return } - - if !a.requireDramaSources(w, r, ids) { - return - } - a.mergeMu.Lock() - defer a.mergeMu.Unlock() - a.mu.Lock() if !a.requireTaskSourcesLocked(w, r, taskSelection{DramaIDs: ids}) { a.mu.Unlock() return } - byDrama := map[string][]*UITask{} - for _, id := range a.taskOrder { - task := a.tasks[id] - if task == nil || task.Status != uiStatusSuccess { - continue - } - byDrama[task.DramaID] = append(byDrama[task.DramaID], cloneUITask(task)) - } + jobs, err := a.enqueueMergesLocked(ids, deleteEpisodes) a.mu.Unlock() - - results := make([]uiMergeResult, 0, len(ids)) - for _, dramaID := range ids { - items := byDrama[dramaID] - res := a.mergeDrama(r.Context(), dramaID, items, deleteEpisodes) - results = append(results, res) + if err != nil { + writeJSON(w, http.StatusConflict, map[string]string{"error": a.redactError(err)}) + return + } + if async { + writeJSON(w, http.StatusAccepted, map[string]any{"accepted": len(jobs), "async": true}) + return + } + results := make([]uiMergeResult, 0, len(jobs)) + for _, job := range jobs { + select { + case <-r.Context().Done(): + return + case <-job.done: + results = append(results, job.result) + } } writeJSON(w, http.StatusOK, map[string]any{"data": results}) } @@ -1673,6 +1685,11 @@ func (a *UIApp) mergeDrama(ctx context.Context, dramaID string, tasks []*UITask, return res } res.Detail = method + if err := cmdCtx.Err(); err != nil { + res.Error = "合并已取消,原分集已保留" + a.setMergeState(res, "failed", 0, false, deleteEpisodes) + return res + } if ok, _ := existingGood(partPath, a.cfg.SkipBytes); !ok { res.Error = "合并输出文件无效" a.setMergeState(res, "failed", 0, false, deleteEpisodes) @@ -1731,8 +1748,9 @@ func (a *UIApp) setMergeState(res uiMergeResult, status string, progress int, sk a.mu.Unlock() } -func readMergeRequest(w http.ResponseWriter, r *http.Request) ([]string, bool, bool) { +func readMergeRequest(w http.ResponseWriter, r *http.Request) ([]string, bool, bool, bool) { var req struct { + Async bool `json:"async"` DramaIDs []string `json:"dramaIds"` DeleteEpisodes bool `json:"deleteEpisodes"` } @@ -1741,10 +1759,10 @@ func readMergeRequest(w http.ResponseWriter, r *http.Request) ([]string, bool, b dec.DisallowUnknownFields() if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json: " + err.Error()}) - return nil, false, false + return nil, false, false, false } ids, ok := cleanIDList(w, req.DramaIDs) - return ids, req.DeleteEpisodes, ok + return ids, req.DeleteEpisodes, req.Async, ok } func (a *UIApp) handleConfig(w http.ResponseWriter, r *http.Request) { diff --git a/internal/app/ui_tasks.go b/internal/app/ui_tasks.go index e62494d..f94bd82 100644 --- a/internal/app/ui_tasks.go +++ b/internal/app/ui_tasks.go @@ -45,7 +45,9 @@ func (a *UIApp) removeTaskLocked(id string) { } a.taskOrder = order if task != nil && !groupRemains { - delete(a.merges, task.DramaID) + if _, _, err := a.mergedPlaybackTaskLocked(mergedPlaybackPrefix + task.DramaID); err != nil && a.mergeJobs[task.DramaID] == nil { + delete(a.merges, task.DramaID) + } } } diff --git a/internal/webui/downloads.js b/internal/webui/downloads.js index d4cc2c7..7ccc05f 100644 --- a/internal/webui/downloads.js +++ b/internal/webui/downloads.js @@ -36,7 +36,7 @@ export function createDownloads(app) { label.appendChild(select); return label; } -function buildGroups(){const byID=new Map();tasks.forEach(task=>{const id=task.dramaId||task.dramaTitle||task.id;let group=byID.get(id);if(!group){group={id,title:task.dramaTitle||'短剧',tasks:[],release:'unknown'};byID.set(id,group);}group.tasks.push(task);if(task.releaseStatus)group.release=task.releaseStatus;});return Array.from(byID.values());} +function buildGroups(){const byID=new Map();tasks.forEach(task=>{const id=task.dramaId||task.dramaTitle||task.id;let group=byID.get(id);if(!group){group={id,title:task.dramaTitle||'短剧',tasks:[],release:'unknown'};byID.set(id,group);}group.tasks.push(task);if(task.releaseStatus)group.release=task.releaseStatus;});for(const [id,merge] of Object.entries(mergeStates)){if(merge.playbackTaskId&&!byID.has(id))byID.set(id,{id,title:merge.dramaTitle||'短剧',tasks:[],release:'unknown'});}return Array.from(byID.values());} function groupMatches(group){const keyword=$('taskSearch').value.trim().toLowerCase();if(keyword&&!group.title.toLowerCase().includes(keyword))return false;const release=$('releaseStatus').value;if(release&&group.release!==release)return false;const status=$('taskStatus').value;const stats=groupStats(group);if(status==='completed')return stats.success===group.tasks.length;if(status==='unfinished')return stats.success!==group.tasks.length;if(status==='failed')return stats.failed>0;if(status==='running')return stats.running+stats.parsing+stats.queued>0;if(status==='paused')return stats.paused>0;if(status==='canceled')return stats.canceled>0;return true;} @@ -103,6 +103,8 @@ function renderTasks(next) { function renderGroup(group) { const stats = groupStats(group); + const merge = mergeStates[group.id]; + if (!group.tasks.length && merge?.playbackTaskId) stats.percent = 100; const wrap = element('article', 'group' + (openGroups.has(group.id) ? ' open' : '')); wrap.dataset.dramaId = group.id; const head = element('div', 'group-head'); @@ -112,14 +114,15 @@ function renderGroup(group) { checkbox.setAttribute('aria-label', '选择合集 ' + group.title); checkbox.dataset.focusKey = 'group-select-' + group.id; const selectedCount = group.tasks.filter(task => selectedTasks.has(task.id)).length; - checkbox.checked = selectedCount === group.tasks.length; + checkbox.checked = group.tasks.length > 0 && selectedCount === group.tasks.length; + checkbox.disabled = !group.tasks.length; checkbox.indeterminate = selectedCount > 0 && !checkbox.checked; checkbox.addEventListener('change', () => selectGroup(group, checkbox.checked)); const info = element('div'); info.appendChild(element('h2', 'group-title', group.title)); - const statuses = [releaseText(group.release), '共 ' + group.tasks.length + ' 集', '完成 ' + stats.success]; + const statuses = group.tasks.length ? [releaseText(group.release), '共 ' + group.tasks.length + ' 集', '完成 ' + stats.success] : ['本地合并视频']; const qualities = [...new Set(group.tasks.map(task => number(task.downloadQuality)))]; - statuses.push('画质:' + (qualities.length === 1 ? qualityLabel(qualities[0]) : '按分集设置')); + if (qualities.length) statuses.push('画质:' + (qualities.length === 1 ? qualityLabel(qualities[0]) : '按分集设置')); for (const [key, label] of [['running','下载中'],['queued','排队'],['parsing','解析中'],['paused','暂停'],['failed','失败'],['canceled','已取消']]) if (stats[key]) statuses.push(label + ' ' + stats[key]); const meta = element('p', 'small', statuses.join(' · ')); meta.appendChild(element('span', 'group-watch-progress', historySuffix(group.id))); @@ -128,8 +131,7 @@ function renderGroup(group) { selection.appendChild(checkbox); heading.append(selection, info); head.append(heading, progressBar(stats.percent), element('p', 'small', stats.percent + '% · 已写入 ' + formatBytes(stats.bytes) + (stats.speed > 0 ? ' · ' + formatBytes(stats.speed) + '/s' : ''))); - const merge = mergeStates[group.id]; - if (merge) head.appendChild(element('p', merge.error ? 'error notice' : 'small notice', '合并:' + ({running:'进行中', success:'完成', failed:'失败'}[merge.status] || merge.status) + ' ' + (merge.progress || 0) + '%' + (merge.detail ? ' · ' + merge.detail : '') + (merge.error ? ' · ' + merge.error : ''))); + if (merge) head.appendChild(element('p', merge.error ? 'error notice' : 'small notice', '合并:' + ({queued:'排队中', running:'进行中', success:'完成', failed:'失败'}[merge.status] || merge.status) + ' ' + (merge.progress || 0) + '%' + (merge.detail ? ' · ' + merge.detail : '') + (merge.error ? ' · ' + merge.error : ''))); const actions = element('div', 'group-actions'); function action(label, key, callback, disabled = false, className = 'secondary') { const node = button(label, callback, disabled, className); @@ -137,21 +139,24 @@ function renderGroup(group) { return node; } const playable = group.tasks.filter(task => task.playable).sort((left, right) => number(left.index) - number(right.index))[0]; - actions.appendChild(action(watchLabel(group.id, '播放'), 'play', () => window.dramaPlayer.openCollection(playable.id, group.title, true), !playable, 'secondary collection-play-button')); + if (merge?.playbackTaskId) actions.appendChild(action('播放全集(' + merge.startEpisode + '–' + merge.endEpisode + '集)', 'play-merged', () => window.dramaPlayer.openCollection(merge.playbackTaskId, group.title, true), false, 'collection-play-button merged-play-button')); + if (group.tasks.length) actions.appendChild(action(merge?.playbackTaskId ? '分集播放' : watchLabel(group.id, '播放'), 'play', () => window.dramaPlayer.openCollection(playable.id, group.title, true), !playable, 'secondary collection-play-button')); const toggle = action(openGroups.has(group.id) ? '收起分集' : '查看分集', 'episodes', () => { if (openGroups.has(group.id)) openGroups.delete(group.id); else openGroups.add(group.id); renderTasks(tasks); }); toggle.setAttribute('aria-expanded', String(openGroups.has(group.id))); + toggle.disabled = !group.tasks.length; actions.appendChild(toggle); if (stats.running + stats.queued + stats.parsing) actions.appendChild(action('暂停', 'pause', () => taskAction('pause', [], [group.id]))); if (stats.paused) actions.appendChild(action('继续', 'resume', () => taskAction('resume', [], [group.id]))); if (stats.failed + stats.canceled) actions.appendChild(action('重试失败', 'retry', () => taskAction('retry', [], [group.id]))); + if (merge && ['queued', 'running'].includes(merge.status)) actions.appendChild(action('取消合并', 'cancel-merge', async () => {try {await post('/api/ui/merge/cancel', {dramaIds: [group.id]}); await pollTasks();} catch (error) {setMessage(error.message, true);}})); const menu = element('details', 'action-menu'); const summary = element('summary', '', '更多操作'); summary.dataset.focusKey = group.id + '-more'; const popover = element('div', 'action-popover'); - popover.append(action('更新本剧', 'update', () => updateGroups([group.id])), action('合并已完成分集', 'merge', () => mergeGroups([group.id]), !stats.success), action('取消本剧下载', 'cancel', () => taskAction('cancel', [], [group.id]), !stats.running && !stats.queued && !stats.parsing && !stats.paused)); + popover.append(action('更新本剧', 'update', () => updateGroups([group.id])), action('合并已完成分集', 'merge', () => mergeGroups([group.id]), !stats.success || ['queued', 'running'].includes(merge?.status)), action('取消本剧下载', 'cancel', () => taskAction('cancel', [], [group.id]), !stats.running && !stats.queued && !stats.parsing && !stats.paused)); menu.append(summary, popover); actions.appendChild(menu); head.appendChild(actions); @@ -203,7 +208,16 @@ async function taskAction(action,ids,dramaIds=[]){const count=dramaIds.length||i async function clearTasks(){const ids=selectedTaskList().map(task=>task.id);if(!ids.length){setMessage('请先勾选需要清理的任务',true);return;}if(!confirm('清理勾选的 '+ids.length+' 个任务记录?进行中的任务会先停止;已下载视频不会删除。'))return;try{const result=await post('/api/ui/tasks/clear',{ids});ids.forEach(id=>selectedTasks.delete(id));renderTasks(result.data||[]);setMessage('已清理 '+(result.removed||0)+' 个任务'+(result.pending?',另有 '+result.pending+' 个正在停止后清理':''));}catch(error){$('taskError').textContent='清理失败:'+error.message;}} -async function mergeGroups(ids){if(!ids.length){setMessage('请先勾选下载合集',true);return;}const deleteEpisodes=$('deleteEpisodesAfterMerge').checked;if(deleteEpisodes&&!confirm('合并成功后删除 '+ids.length+' 个合集已合并的分集文件,确认继续?'))return;setMessage('正在合并 '+ids.length+' 部,下载区会显示进度');try{const result=await post('/api/ui/merge',{dramaIds:ids,deleteEpisodes});const items=result.data||[];const failed=items.filter(item=>!item.ok);setMessage('合并完成:成功 '+(items.length-failed.length)+',失败 '+failed.length,failed.length>0);await pollTasks();}catch(error){$('taskError').textContent='合并失败:'+error.message;}} +async function mergeGroups(ids) { + if (!ids.length) {setMessage('请先勾选下载合集', true); return;} + const deleteEpisodes = $('deleteEpisodesAfterMerge').checked; + if (deleteEpisodes && !confirm('合并成功后删除 ' + ids.length + ' 个合集已合并的分集文件,确认继续?')) return; + try { + const result = await post('/api/ui/merge', {dramaIds: ids, deleteEpisodes, async: true}); + setMessage('已提交后台合并 ' + result.accepted + ' 部,可关闭或刷新页面,进度在下载区查看'); + await pollTasks(); + } catch (error) {$('taskError').textContent = '提交合并失败:' + error.message;} +} function renderActivity(all) { @@ -249,7 +263,7 @@ function toggleBatch() { function schedule(restart = true) { if (app.viewer?.onlineOnly) return; - const active = hasActiveTasks || ['idle', 'downloading', 'verifying'].includes(ffmpegStatus) || Object.values(mergeStates).some(state => state.status === 'running'); + const active = hasActiveTasks || ['idle', 'downloading', 'verifying'].includes(ffmpegStatus) || Object.values(mergeStates).some(state => ['queued', 'running'].includes(state.status)); const delay = document.hidden ? 30000 : active || !$('downloadsPage').hidden ? 2000 : 15000; if (!restart && polling && pollTimer && delay === scheduledDelay) return; clearTimeout(pollTimer); diff --git a/internal/webui/history.js b/internal/webui/history.js index 0b01b98..e915621 100644 --- a/internal/webui/history.js +++ b/internal/webui/history.js @@ -23,6 +23,7 @@ function progressText(id) { const entry = typeof id === 'object' ? id : get(id); if (!entry) return ''; + if (entry.taskId?.startsWith('merged:')) return entry.completed ? '全集已看完' : '全集看到 ' + clock(entry.position); if (entry.completed) return entry.index >= entry.total ? '已看至最新' : '第' + entry.episode + '集已看完'; return '看到第' + entry.episode + '集 ' + clock(entry.position); } diff --git a/internal/webui/index.html b/internal/webui/index.html index 023a3ca..4468337 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -162,7 +162,7 @@

Emby 探测或取流结束即释放会话名额。处理并发设为 0 可禁用该类任务;原始播放不占转码名额。

允许尝试客户端直连的站源

-

默认关闭。检查通过后尝试直连,失败退回中转;已发出的源站链接可能在账号权限变更后继续有效。

+

先开启“优先原始 MP4 / HLS 播放”,再选择直连站源。满足源站鉴权、编码、HTTPS 和跨域条件才会直连,失败退回中转。

保存后自动持久化。降低并发不终止正在下载的任务;代理变更用于后续连接,失败任务可重试。同一站源的剧库请求顺序执行,下载与剧库请求使用独立并发。
diff --git a/internal/webui/playback-settings.js b/internal/webui/playback-settings.js index 7a41b98..753083c 100644 --- a/internal/webui/playback-settings.js +++ b/internal/webui/playback-settings.js @@ -3,6 +3,13 @@ import { $, api, post } from './ui-core.js'; export function createPlaybackSettings() { let settings = null; const fields = ['maxSessions', 'maxMediaRequests', 'maxRemuxJobs', 'maxAudioTranscodes', 'maxVideoTranscodes', 'maxPrefetchJobs', 'cacheMB']; + function updateDirect() { + const enabled = $('originalPlaybackEnabled').checked; + $('playbackDirectSources').querySelectorAll('input').forEach(input => {input.disabled = !enabled;}); + $('playbackDirectHint').textContent = enabled + ? '允许尝试直连,不保证每集生效。需要服务器解密、转码,或源站鉴权、HTTPS、跨域检查未通过时仍走中转;客户端连接失败也会自动回退。' + : '原始 MP4 / HLS 播放已关闭,已勾选的直连站源也不会生效。先开启上方开关,再保存播放设置。'; + } function render(result) { settings = result.settings; $('originalPlaybackEnabled').checked = settings.enabled; @@ -18,6 +25,7 @@ export function createPlaybackSettings() { label.append(input, document.createTextNode(source.name)); sources.appendChild(label); } + updateDirect(); } async function load() { try {render(await api('/api/ui/admin/playback'));} @@ -32,10 +40,10 @@ export function createPlaybackSettings() { directSources: [...$('playbackDirectSources').querySelectorAll('input:checked')].map(input => input.value)}; for (const name of fields) value[name] = Number($('playback-' + name).value); render(await post('/api/ui/admin/playback', value)); - $('playbackSettingsStatus').textContent = '播放设置已保存,新任务按当前限制执行。'; + $('playbackSettingsStatus').textContent = '播放设置已保存,新任务按当前限制执行。' + (!value.enabled && value.directSources.length ? '原始播放已关闭,直连暂不生效。' : ''); } catch (error) {$('playbackSettingsStatus').textContent = '保存失败:' + error.message;} finally {button.disabled = false;} } - function init() {$('savePlaybackSettings').onclick = save; return load();} + function init() {$('savePlaybackSettings').onclick = save; $('originalPlaybackEnabled').onchange = updateDirect; return load();} return {init}; } diff --git a/internal/webui/player-mobile.js b/internal/webui/player-mobile.js index e51f3bb..19b008e 100644 --- a/internal/webui/player-mobile.js +++ b/internal/webui/player-mobile.js @@ -63,8 +63,8 @@ panel.classList.toggle('playback-loading', Boolean(value.loading)); panel.classList.toggle('playback-buffering', waiting); if (lastTitle !== value.title) {node('mobilePlayerTitle').textContent = node('mobileHeadingTitle').textContent = value.title || '正在加载'; lastTitle = value.title;} - node('mobileEpisodeLabel').textContent = value.episode ? '第 ' + value.episode + ' 集' : '正在加载'; - node('mobileEpisodeSummary').textContent = [value.releaseStatus === 'finished' ? '已完结' : value.releaseStatus === 'ongoing' ? '连载中' : '', value.total ? '全 ' + value.total + ' 集' : '获取分集中'].filter(Boolean).join(' · '); + node('mobileEpisodeLabel').textContent = value.episode ? (value.merged ? value.episode : '第 ' + value.episode + ' 集') : '正在加载'; + node('mobileEpisodeSummary').textContent = [value.releaseStatus === 'finished' ? '已完结' : value.releaseStatus === 'ongoing' ? '连载中' : '', value.merged ? '本地合并视频' : value.total ? '全 ' + value.total + ' 集' : '获取分集中'].filter(Boolean).join(' · '); node('mobileEpisodeVIP').hidden = !value.vip; node('mobileRateBtn').textContent = node('mobileLandscapeRateBtn').textContent = video.playbackRate === 1 ? '倍速' : (video.playbackRate || 1) + ' 倍'; const selectedQuality = quality.selectedOptions[0]?.textContent || '自动'; diff --git a/internal/webui/player.js b/internal/webui/player.js index c8289c1..5f859b9 100644 --- a/internal/webui/player.js +++ b/internal/webui/player.js @@ -52,7 +52,7 @@ const prefetchToggle = node('prefetchNextEpisode'); const prefetchStatus = node('prefetchStatus'); try {prefetchToggle.checked = localStorage.getItem('juku.playback.prefetchNext') !== 'false';} catch (_) {} - const mobile = window.JukuPlayerMobile({panel, video, state: () => ({title: dramaName, episode: episodes[currentIndex - 1]?.episode, total: episodes.length, duration: playbackDuration, loading, releaseStatus, vip: episodes[currentIndex - 1]?.vip}), showEpisodes: showEpisodePanel, showPreferences: section => setPlayerPreferences(true, section)}); + const mobile = window.JukuPlayerMobile({panel, video, state: () => ({title: dramaName, merged: episodes[currentIndex - 1]?.merged, episode: episodes[currentIndex - 1]?.episode, total: episodes.length, duration: playbackDuration, loading, releaseStatus, vip: episodes[currentIndex - 1]?.vip}), showEpisodes: showEpisodePanel, showPreferences: section => setPlayerPreferences(true, section)}); const controls = window.JukuPlayerControls({panel, video, mobile: () => mobile.active(), rotation: () => mobile.rotation(), canSwipe: () => panel.open && episodes.length > 0 && !panel.classList.contains('episodes-open') && node('playerPreferences').hidden, swipe: direction => { const next = currentIndex + direction; if (next < 1) return '已经是第一集'; @@ -281,7 +281,7 @@ button.setAttribute('aria-current', String(index === currentIndex)); button.classList.toggle('watched', completedEpisodes.has(index)); } - node('playbackEpisodeCount').textContent = episodes.length ? '第 ' + (episodes[currentIndex - 1]?.episode || currentIndex || '—') + ' 集 / 共 ' + episodes.length + ' 集' : '正在获取分集'; + node('playbackEpisodeCount').textContent = episodes[currentIndex - 1]?.merged ? episodes[currentIndex - 1].title : episodes.length ? '第 ' + (episodes[currentIndex - 1]?.episode || currentIndex || '—') + ' 集 / 共 ' + episodes.length + ' 集' : '正在获取分集'; node('jumpEpisode').max = episodes.length || 1; node('jumpEpisode').disabled = !episodes.length; node('jumpEpisodeBtn').disabled = !episodes.length; @@ -322,10 +322,10 @@ episodeButtons = episodes.slice(episodePage * 30, episodePage * 30 + 30).map(episode => { const button = document.createElement('button'); button.className = 'secondary'; - button.textContent = episode.episode + (episode.vip ? ' VIP' : ''); + button.textContent = episode.merged ? '全集' : episode.episode + (episode.vip ? ' VIP' : ''); button.classList.toggle('vip-episode', Boolean(episode.vip)); button.title = (episode.title || '第 ' + episode.episode + ' 集') + (episode.vip ? ' · VIP,仅试看' : ''); - button.setAttribute('aria-label', '播放第 ' + episode.episode + ' 集' + (episode.vip ? ',VIP,仅试看' : '')); + button.setAttribute('aria-label', episode.merged ? '播放' + episode.title : '播放第 ' + episode.episode + ' 集' + (episode.vip ? ',VIP,仅试看' : '')); button.dataset.episodeIndex = episode.index; button.addEventListener('click', () => {playEpisode(episode.index); closeEpisodePanel();}); episodeList.appendChild(button); @@ -515,8 +515,11 @@ } function updatePlaybackHint(source) { - if (!collectionMode) { - node('playbackHint').textContent = '直接观看,不加入下载任务。开启预缓存后,临近播完时提前准备下一集;关闭窗口即停止取流并释放缓存。'; + if (source === 'merged' || episodes[currentIndex - 1]?.merged) { + node('playbackHint').textContent = '正在播放本地合并文件,支持拖动进度和继续观看。'; + } else if (!collectionMode) { + const delivery = mediaPlan?.delivery === 'redirect' ? '当前为客户端直连,视频直接从源站获取。' : mediaPlan?.delivery === 'proxy' ? '当前由服务器中转视频。' : ''; + node('playbackHint').textContent = delivery + '直接观看,不加入下载任务。开启预缓存后,临近播完时提前准备下一集;关闭窗口即停止取流并释放缓存。'; } else { const prefix = source === 'local' ? '本集播放本地已完成文件。' : source === 'online' ? '本集在线缓冲,同时使用原下载任务保存视频。' : '已完成分集优先播放本地,播到未完成分集时自动下载该集。'; node('playbackHint').textContent = prefix + '只补下载播到的分集;关闭播放器不取消下载,可在下载合集中暂停或取消。'; @@ -691,7 +694,7 @@ let reader; try { if (collectionMode && preparedIndex !== index) { - statusText.textContent = '正在检查本地分集并准备下载…'; + statusText.textContent = episodes[index - 1].merged ? '正在读取本地合并文件…' : '正在检查本地分集并准备下载…'; const preparation = await requestJSON('/api/ui/playback/prepare', {session: currentSession, episode: index}, signal); if (signal.aborted) throw abortError(); preparedIndex = index;