Files
OpenList/internal/transcode/local_runner.go
T
25e4eb663f feat(func): media library (#2210)
* feat(func): media library

* feat(func): media library

* feat(func): media library

* feat(func): media library build test

* feat(func): media library build test

* feat(func): media library fix remote fetch error

* feat(func): media library fix remote fetch error

* feat(func): media library fix remote fetch error

* feat(func): media library support select

* feat(func): media library support select

* feat(func): media library support select

* feat(func): media library support select

* feat(func): media library support change api url

* feat(func): media library support change api url

* feat(func): media library support change api url

* feat(media): 并发刮削(默认 5)+ 单条落库
增强 TMDB 搜索:模糊匹配 + 多候选关键词
管理页面新增导入/导出媒体库(全部 / 按扫描路径)
缩略图路径默认值改为 imgs

* feat(media): 清空刮削、删除已失效

* fix(media): fix api proxy path

* fix(media): fix lrc file display & music id3

* fix(media): fix media scan sub path error

* feat(media): support cloud codec via ffmpeg

* feat(media): support cloud codec via ffmpeg

* feat(media): support cloud codec via ffmpeg

* feat(media): support cloud codec via ffmpeg

* feat(media): 提升刮削精准度,支持模糊刮削

* fix(media): clean up imports and improve FetchFileReader logic

* feat(media): 提升刮削精准度,支持模糊刮削

* feat(media): 刮削支持自定义刮削数量,避免太占性能

* feat(media): 刮削支持自定义刮削数量,避免太占性能

* feat(media): 刮削修复写入作品名问题

* fix(media): return 404 on missing item_id and fix unscraped item query

- Validate GetMediaItemByID result before spawning goroutine in
  StartMediaScrape; return 404 when item_id > 0 but item is not found
- Replace `description` check with `plot` check in GetUnscrappedItems
  since scrapers populate Plot, not Description, to avoid re-selecting
  already-scraped items on every run

* Apply remaining changes

* feat(media): add master switch, app keys, STRM, Emby and OpenSubsonic APIs

* feat(media): allow any logged-in user to manage their own media app keys

* feat(media): allow signing arbitrary media path for local player

* fix(media): always return an array for media status types

* fix(media): persist per-type scraper config and apply it to thumbnails

* feat(media): add local thumbnail endpoint and cover proxy with fallback gateway

* fix(media): allow guest media access, per-file local thumbnails and optional cover proxy

* feat(media): thumbnail storage modes and STRM same-directory output

* feat(media): Google Books/OpenLibrary sources and remote cover download

* chore(media): rename transcode cache dir to ctmp and log thumbnail storage dir

* feat(media): progress and favorites API shared with Emby/Subsonic

* chore: ignore runtime thumbnail directory

* feat(media): add preferred scrape language and namespace protocol endpoints under @

* fix(media): harden anonymous emby images, pagination and cover proxy SSRF

* fix(emby): allow anonymous user discovery and login before auth

* chore: drop redundant media build workflow and protocol doc

* fix(media): ID3v2.4 syncsafe frame size and preserve Hidden flag on rescan

* fix(media): address security review (path traversal, guest bypass, cover XSS, auth rate limit, SSRF, quotas, migration)

* fix(media): 清理失效条目时只删除确认不存在的记录

此前 fs.Get 返回任何错误都视为失效并硬删除,驱动故障/超时/鉴权失败时会误删有效媒体记录。现在仅 errs.IsObjectNotFound 才删除,其余错误跳过并在响应中返回 skipped 计数。

* fix(transcode): 播放凭据与 Worker 回写凭据分离,并收紧 profile 与鉴权校验

- 新增 Job.PlayToken(json 标签为 -),master/playlist/切片只认播放凭据,播放器不再拿到可上传、可结束任务的 callback_token

- Worker 上传切片时校验 profile 必须属于任务声明的档位

- TCMaster 与 WorkerJobFinish 改用恒定时间比较,避免时序侧信道

* fix(transcode): 含冒号的本地文件名可转码,补基址缺失与重新激活配额

- ValidSourceInput 不再按冒号位置判协议:本地输入只要求以 / 开头或 Windows 盘符路径,concat:/subfile: 等前缀仍被拒且由 -protocol_whitelist 兜底(修复 /mnt/x/Star Wars: IV.mkv 被误判)

- trustedInternalBaseURL 返回空串时直接 503 并给出明确原因,不再拼出 /d/xxx 让 ffmpeg 报文件不存在

- 远程输入白名单收窄为 http,https,tcp,tls(去掉 file 与 crypto)

- Reactivate 复用同一套单用户配额,避免「先让任务闲置取消、再批量激活」绕过 SubmitLimited;配额不足返回 429

- OwnerID 改为配额键:普通用户 user:<id>,访客 guest:<clientIP>,避免单个匿名访客占满全部匿名配额;补充对应单测

* fix(media): 收紧缩略图解码内存、跳过超长条目可见化,并补 CI 单测

- MaxImagePixels 从 64MP 降到 32MP,宽高改 int64 相乘防 32 位溢出,解码阶段加 12 路全局信号量

- 封面解码失败退回原始字节时限制 512KB,避免十几 MB 的 base64 写进 cover 列(豆瓣/本地书籍三处)

- 扫描进度新增 Skipped 计数,入库被跳过的超长路径会在完成消息与日志中体现

- 数据库迁移失败日志补充 OPENLIST_MEDIA_MIGRATE_WIPE=1 的处置提示

- Subsonic 认证只对「确实带了凭据却校验失败」计数,裸探测不再刷新 IP 锁定

- goexif 从 indirect 调整为直接依赖(internal/media/scraper/image.go 直接 import)

- CI 新增 go test ./internal/db/... ./internal/transcode/... ./internal/media/...

* fix(transcode): 配额/队列满时仍放行已缓存切片,并同步 worker 协议白名单

- TCSegment/TCPlaylist: Reactivate 返回 ErrUserQuota/ErrQueueFull 时不再直接 429,改为标记 quotaBlocked,缓存命中的切片与 playlist 仍可读,只有缓存缺失才返回 429
- Reactivate: 补充全局排队上限 (MaxPendingJobs) 检查,避免批量激活顶爆队列,并补单测
- cmd/worker: 远程输入协议白名单与 master 同步为 http,https,tcp,tls(去掉 file/crypto)
- scraper: decodeSem 容量 12 -> 3,避免信号量大于默认刮削并发而形同失效

* feat(emby): 完善客户端兼容层并修复认证锁定,Subsonic 复用统一密钥校验

- Emby: 用户 ID 改用 GUID 并兼容数字/旧 ID;补齐 /Users/{id}/Authenticate、Sessions 与 WebSocket、首页分区、合集/播放列表、字幕流与歌词、Series/Season 结构
- Emby: 版本号返回客户端可接受的兼容版本,/Users/Public 返回可登录用户以渲染用户选择
- Subsonic: 支持 enc: 十六进制密码编码;app key 校验与 Emby 共用 LookUpAppKeyUser
- 限流: 认证被拒时不再刷新锁定过期时间,避免客户端持续轮询把 IP 永久锁死

* fix(media): 认证失败计数增加用户名维度,防止伪造 X-Forwarded-For 绕过锁定

- media_ratelimit: 新增 mediaAuthLockedFor / mediaAuthFailedFor / mediaAuthSucceededFor,IP 与用户名双维度计数;达到上限同样锁定 5 分钟且被拒时不续期
- emby: 按用户名登录与按 id 登录都检查用户名维度;失败计入两个维度,成功清除两个维度
- subsonic: subAuth 与 429 分支改用双维度判定;成功时用真实用户名清理计数(apiKey 认证时 u 参数为空)
- 仍保留主干 5 次/5 分钟的策略,代理信任范围(SetTrustedProxies)留给项目层面单独处理

* fix(media): 匿名封面按访客身份过滤,认证日志脱敏并收紧限流计数

- embyImage: 匿名封面不再走 loadEmbyLibraryAll(全库可见),改为 loadEmbyLibrary(guest):
  hidden / base_path 之外 / meta 受保护的条目都不可见,访客被禁用时直接 401
- embyUnauthorized: 只记录 Path、UA 与是否带 token,避免 query 里的 Pw/Password/api_key
  和 X-Emby-Authorization 里的设备令牌写进日志
- media_ratelimit: 失败计数 Set 时带 5min TTL,避免 IP/用户名 key 无限增长(用户名来自请求参数)
- auth.loginHash: 锁定期间不再续期,失败计数同样带 TTL,与媒体侧语义一致
- /System/Endpoint: 按 RemoteAddr 判断 IsLocal/IsInNetwork;/System/Info 不再返回真实监听端口

* feat(media): PDF 批注持久化

- 新增 MediaAnnotation 模型:按 (user_id, item_id, remote_id) 唯一,anchor 原样保存查看器批注对象,不重复建模页码/坐标
- GET/POST /fs/media/annotations:读取当前用户批注、按 remote_id 增量 upsert/delete(单条 512KB、单次 8MB、最多 500 条)
- 批注为用户私有数据:未登录返回空列表,访客写入 403,并校验条目可见性与访问权限
- 条目删除 / 清空类型 / 清空扫描路径 / 批量删除时,在同一事务内清理对应批注

* fix(media): 选集签名前校验最终文件权限;转码在 ffprobe 前预检配额

- GetMediaStreamURL / GetMediaStrm: 解析选集后对最终 rawPath 再执行 CanUserAccessPath,避免「目录可见但 meta 隐藏规则禁止其中某个文件」时仍能拿到有效签名(签名之后下载链路不会重跑隐藏规则)
- TranscodePlay: ProbeSource 之前调用 Scheduler.Capacity 预检配额,已满直接 429,不再先起最长 30s 的 ffprobe
- 新增探测并发闸门(全局 8 / 每用户 3),覆盖预检与 SubmitLimited 之间的并发窗口;用 defer 释放,探测 panic 也不会占死槽位
- 补 Capacity 单测

---------

Co-authored-by: j2rong4cn <j2rong@qq.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: pikachuren <pikachuren@example.com>
2026-10-24 17:34:29 +08:00

773 lines
23 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package transcode
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/OpenListTeam/OpenList/v4/internal/conf"
"github.com/OpenListTeam/OpenList/v4/internal/setting"
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
)
// limitedBuffer 是一个有容量上限的 io.Writer,超出 maxSize 后静默丢弃。
// 用于捕获 ffmpeg stderr,防止异常文件导致无限内存增长。
type limitedBuffer struct {
data []byte
maxSize int
}
func (b *limitedBuffer) Write(p []byte) (int, error) {
remain := b.maxSize - len(b.data)
if remain <= 0 {
return len(p), nil // 静默丢弃
}
if len(p) > remain {
p = p[:remain]
}
b.data = append(b.data, p...)
return len(p), nil
}
func (b *limitedBuffer) String() string {
return string(b.data)
}
// LocalRunner 本地内置 worker,直接调用本机 ffmpeg
type LocalRunner struct {
mgr *Manager
workerID string
stopCh chan struct{}
wg sync.WaitGroup
// 运行中 job 的 cancel 管理:jobID -> cancelFunc
runMu sync.Mutex
runningJobs map[string]context.CancelFunc
}
func NewLocalRunner(mgr *Manager) *LocalRunner {
return &LocalRunner{
mgr: mgr,
stopCh: make(chan struct{}),
runningJobs: make(map[string]context.CancelFunc),
}
}
// Start 注册本地 worker 并启动消费协程
func (r *LocalRunner) Start() error {
concurrency := setting.GetInt(conf.TranscodeLocalConcurrency, 1)
if concurrency <= 0 {
concurrency = 1
}
hwaccel := setting.GetStr(conf.TranscodeHWAccel, "none")
hostname, _ := os.Hostname()
w := r.mgr.Registry.Register(&RegisterRequest{
Name: "local-" + hostname,
Version: "embedded",
Capacity: concurrency,
HWAccel: []string{hwaccel},
CodecsDecode: []string{"h264", "hevc", "av1", "vp9", "mpeg4", "mpeg2video"},
CodecsEncode: []string{"h264", "hevc"},
MaxResolution: "3840x2160",
Tags: []string{"local", "embedded"},
}, true)
r.workerID = w.ID
utils.Log.Infof("[transcode] local worker started: id=%s capacity=%d hwaccel=%s", w.ID, concurrency, hwaccel)
for i := 0; i < concurrency; i++ {
r.wg.Add(1)
go r.loop()
}
return nil
}
func (r *LocalRunner) Stop() {
close(r.stopCh)
// 取消所有运行中的 FFmpeg 进程
r.runMu.Lock()
for id, cancel := range r.runningJobs {
utils.Log.Infof("[transcode] stopping ffmpeg for job %s (runner shutdown)", id)
cancel()
}
r.runMu.Unlock()
r.wg.Wait()
r.mgr.Registry.Unregister(r.workerID)
}
// CancelJob 外部取消指定 job 的 FFmpeg 进程(由 Scheduler.Cancel / 空闲超时调用)
func (r *LocalRunner) CancelJob(jobID string) bool {
r.runMu.Lock()
cancel, ok := r.runningJobs[jobID]
r.runMu.Unlock()
if ok {
utils.Log.Infof("[transcode] killing ffmpeg for job %s (external cancel)", jobID)
cancel()
}
return ok
}
// RunningJobIDs 返回当前正在运行的所有 job ID 列表
func (r *LocalRunner) RunningJobIDs() []string {
r.runMu.Lock()
defer r.runMu.Unlock()
ids := make([]string, 0, len(r.runningJobs))
for id := range r.runningJobs {
ids = append(ids, id)
}
return ids
}
func (r *LocalRunner) loop() {
defer r.wg.Done()
for {
select {
case <-r.stopCh:
return
default:
}
jobs := r.mgr.Scheduler.Claim(r.workerID, 1, 5*time.Second)
if len(jobs) == 0 {
continue
}
for _, j := range jobs {
r.runJob(j)
}
}
}
// runJob 执行一个转码任务(单 profile,HLS 输出,边转边写入 cache)
func (r *LocalRunner) runJob(job *Job) {
utils.Log.Infof("[transcode] local runner picked job %s (%s)", job.ID, job.Path)
if len(job.Profiles) == 0 {
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: "no profiles"})
return
}
profile := job.Profiles[0]
timeoutMin := setting.GetInt(conf.TranscodeJobTimeoutMin, 120)
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMin)*time.Minute)
defer cancel()
// 注册到 runningJobs,支持外部取消
r.runMu.Lock()
r.runningJobs[job.ID] = cancel
r.runMu.Unlock()
defer func() {
r.runMu.Lock()
delete(r.runningJobs, job.ID)
r.runMu.Unlock()
}()
// 输出目录
jc, err := r.mgr.Cache.getOrCreate(job.ID, profile.Name)
if err != nil {
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: err.Error()})
return
}
outDir := jc.BaseDir
// 【智能调度】如果探测到了视频总时长,启用 chunk 模式(按需分段并发转码)
// 否则回退到旧的"一次性整段"模式(兼容 ffprobe 失败的场景)
if job.Probe.Duration > 0 && r.mgr.Chunks != nil {
r.runJobByChunks(ctx, job, profile, outDir)
return
}
// === 旧路径:一次性整段转码 ===
r.runJobLegacy(ctx, job, profile, outDir)
}
// runJobByChunks 智能 chunk 调度模式:按需启动 ffmpeg 进程,支持随机访问
func (r *LocalRunner) runJobByChunks(ctx context.Context, job *Job, profile Profile, outDir string) {
startedAt := time.Now()
// 启动 watchSegments:周期扫描目录把切片登记到 cache
stopWatcher := make(chan struct{})
var watcherWG sync.WaitGroup
watcherWG.Add(1)
go func() {
defer watcherWG.Done()
r.watchSegments(job, profile, outDir, stopWatcher)
}()
// 创建 chunk session(会立刻启动 chunk-0 + 后台维护协程)
session, err := r.mgr.Chunks.StartSession(job, profile, outDir)
if err != nil {
close(stopWatcher)
watcherWG.Wait()
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: err.Error()})
return
}
// 阻塞等待全部 chunk 完成 / 任务被取消
select {
case <-session.WaitDone():
// 所有 chunk 都 Done,正常完成
case <-ctx.Done():
// 外部取消:通知 chunk session 杀掉所有 ffmpeg
r.mgr.Chunks.CancelSession(job.ID, profile.Name)
}
close(stopWatcher)
watcherWG.Wait()
r.scanAndPublish(job, profile, outDir, true)
if ctx.Err() != nil {
utils.Log.Infof("[transcode] job %s chunk session cancelled", job.ID)
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobCancelled,
Error: "cancelled: " + ctx.Err().Error(),
})
return
}
utils.Log.Infof("[transcode] job %s completed in %.1fs (chunk mode)", job.ID, time.Since(startedAt).Seconds())
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobFinished,
Stats: JobStats{Elapsed: time.Since(startedAt).Seconds()},
})
}
// runJobLegacy 旧路径:一次性整段转码(不知道总时长时回退使用)
func (r *LocalRunner) runJobLegacy(ctx context.Context, job *Job, profile Profile, outDir string) {
playlistPath := filepath.Join(outDir, "playlist.m3u8")
segPattern := filepath.Join(outDir, "seg-%d.ts")
args := buildFFmpegArgs(job, profile, segPattern, playlistPath)
ffmpegPath := setting.GetStr(conf.TranscodeFFmpegPath, "ffmpeg")
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
stderr := &limitedBuffer{maxSize: 64 * 1024}
cmd.Stderr = stderr
startedAt := time.Now()
if err := cmd.Start(); err != nil {
_ = r.mgr.Scheduler.Finish(&FinishRequest{JobID: job.ID, Status: JobFailed, Error: err.Error()})
return
}
stopWatcher := make(chan struct{})
var watcherWG sync.WaitGroup
watcherWG.Add(1)
go func() {
defer watcherWG.Done()
r.watchSegments(job, profile, outDir, stopWatcher)
}()
err := cmd.Wait()
close(stopWatcher)
watcherWG.Wait()
r.scanAndPublish(job, profile, outDir, true)
if err != nil {
if ctx.Err() != nil {
utils.Log.Infof("[transcode] job %s ffmpeg killed (context cancelled)", job.ID)
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobCancelled,
Error: "cancelled: " + ctx.Err().Error(),
})
} else {
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobFailed,
Error: fmt.Sprintf("ffmpeg failed: %v, stderr=%s", err, truncate(stderr.String(), 1024)),
})
}
return
}
utils.Log.Infof("[transcode] job %s completed in %.1fs (legacy mode)", job.ID, time.Since(startedAt).Seconds())
_ = r.mgr.Scheduler.Finish(&FinishRequest{
JobID: job.ID,
Status: JobFinished,
Stats: JobStats{Elapsed: time.Since(startedAt).Seconds()},
})
}
// watchSegments 周期性扫描输出目录,把新切片登记到 Cache
// 【内存优化】把扫描频率从 500ms 降到 2s,减少每次 os.ReadDir 和 parseFFmpegPlaylistDurations
// 触发的字符串/字节切片分配。chunk 模式下每个 chunk 完成时还会主动调用 publishChunkSegments,
// 所以这里只需要兜底登记中间切片,2s 频率完全够用。
func (r *LocalRunner) watchSegments(job *Job, profile Profile, dir string, stop chan struct{}) {
t := time.NewTicker(2 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
r.scanAndPublish(job, profile, dir, false)
}
}
}
func (r *LocalRunner) scanAndPublish(job *Job, profile Profile, dir string, finalScan bool) {
jc, err := r.mgr.Cache.getOrCreate(job.ID, profile.Name)
if err != nil {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
type segFile struct {
seq int
path string
size int64
}
var segs []segFile
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if !strings.HasPrefix(name, "seg-") || !strings.HasSuffix(name, ".ts") {
continue
}
numStr := strings.TrimSuffix(strings.TrimPrefix(name, "seg-"), ".ts")
seq, err := strconv.Atoi(numStr)
if err != nil {
continue
}
fi, err := e.Info()
if err != nil {
continue
}
segs = append(segs, segFile{seq: seq, path: filepath.Join(dir, name), size: fi.Size()})
}
sort.Slice(segs, func(i, j int) bool { return segs[i].seq < segs[j].seq })
// 【优化】解析 ffmpeg 写出的 playlist.m3u8 获取每个切片的真实时长
// 避免硬编码 segDur 导致 HLS.js 显示总时长不准确
realDurations := parseFFmpegPlaylistDurations(filepath.Join(dir, "playlist.m3u8"))
// ffmpeg 在写入 .ts 时是边写边写,稳妥起见仅登记除最后一片以外的切片(最后一片可能未写完)
limit := len(segs)
if !finalScan && limit > 0 {
limit--
}
defaultDur := float64(setting.GetInt(conf.TranscodeSegmentDuration, 6))
for i := 0; i < limit; i++ {
s := segs[i]
jc.mu.RLock()
_, exists := jc.segments[s.seq]
jc.mu.RUnlock()
if exists {
continue
}
// 优先用 ffmpeg playlist 中的真实时长
dur := defaultDur
if d, ok := realDurations[s.seq]; ok && d > 0 {
dur = d
}
// 直接登记,不复制(已经在 cache 目录里了)
jc.mu.Lock()
jc.segments[s.seq] = &SegmentInfo{Seq: s.seq, Duration: dur, Size: s.size, Path: s.path}
var toClose []chan struct{}
for sk, chs := range jc.waiters {
if sk <= s.seq {
toClose = append(toClose, chs...)
delete(jc.waiters, sk)
}
}
jc.mu.Unlock()
for _, ch := range toClose {
close(ch)
}
// 第一片就绪 → 通知 job ready
if s.seq == 0 {
job.MarkReady()
}
}
if finalScan {
jc.mu.Lock()
jc.final = true
jc.mu.Unlock()
}
}
// publishChunkSegments 把 [startSeq, endSeq) 范围内已经写完的切片登记到 cache。
// 与 scanAndPublish 不同,这个方法专门用于"某个 chunk 的 ffmpeg 已经退出"的场景,
// 此时该范围的所有 .ts 文件都是完整的,不能再丢掉最大序号的那片(避免边界切片永远丢失)。
func (r *LocalRunner) publishChunkSegments(job *Job, profile Profile, dir string, startSeq, endSeq int) {
jc, err := r.mgr.Cache.getOrCreate(job.ID, profile.Name)
if err != nil {
return
}
defaultDur := float64(setting.GetInt(conf.TranscodeSegmentDuration, 6))
registered := 0
for seq := startSeq; seq < endSeq; seq++ {
jc.mu.RLock()
_, exists := jc.segments[seq]
jc.mu.RUnlock()
if exists {
continue
}
fpath := filepath.Join(dir, fmt.Sprintf("seg-%d.ts", seq))
fi, err := os.Stat(fpath)
if err != nil || fi.IsDir() || fi.Size() == 0 {
continue
}
jc.mu.Lock()
jc.segments[seq] = &SegmentInfo{Seq: seq, Duration: defaultDur, Size: fi.Size(), Path: fpath}
var toClose []chan struct{}
for sk, chs := range jc.waiters {
if sk <= seq {
toClose = append(toClose, chs...)
delete(jc.waiters, sk)
}
}
jc.mu.Unlock()
for _, ch := range toClose {
close(ch)
}
if seq == 0 {
job.MarkReady()
}
registered++
}
if registered > 0 {
utils.Log.Infof("[transcode][chunk] published %d segments [%d, %d) for job=%s",
registered, startSeq, endSeq, job.ID)
}
}
// playlistDurCache 缓存 m3u8 解析结果,按文件 mtime+size 失效。
// 【内存优化】scanAndPublish 每 2 秒一次,每次都会解析同一个 m3u8(长视频几百 KB),
// strings.Split 会产生大量临时字符串切片造成 GC 压力。用缓存减少 99% 的重复解析。
type playlistDurCacheEntry struct {
mtime time.Time
size int64
durs map[int]float64
}
var (
playlistDurCache = make(map[string]*playlistDurCacheEntry)
playlistDurCacheMu sync.Mutex
)
// parseFFmpegPlaylistDurations 解析 ffmpeg 写出的 m3u8,返回 seq -> duration 的映射
// 用于获取每个切片的真实时长,避免后端用硬编码 segDur 导致总时长显示偏差
func parseFFmpegPlaylistDurations(path string) map[int]float64 {
fi, err := os.Stat(path)
if err != nil {
return map[int]float64{}
}
// 命中缓存:mtime + size 都没变就直接复用
playlistDurCacheMu.Lock()
if e, ok := playlistDurCache[path]; ok && e.mtime.Equal(fi.ModTime()) && e.size == fi.Size() {
playlistDurCacheMu.Unlock()
return e.durs
}
playlistDurCacheMu.Unlock()
out := make(map[int]float64)
data, err := os.ReadFile(path)
if err != nil {
return out
}
lines := strings.Split(string(data), "\n")
var pendingDur float64
for _, line := range lines {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#EXTINF:") {
// #EXTINF:6.000000,
body := strings.TrimPrefix(line, "#EXTINF:")
body = strings.TrimSuffix(body, ",")
d, err := strconv.ParseFloat(strings.TrimSpace(body), 64)
if err == nil {
pendingDur = d
}
continue
}
if strings.HasSuffix(line, ".ts") && strings.HasPrefix(line, "seg-") {
numStr := strings.TrimSuffix(strings.TrimPrefix(line, "seg-"), ".ts")
if seq, err := strconv.Atoi(numStr); err == nil && pendingDur > 0 {
out[seq] = pendingDur
}
pendingDur = 0
}
}
// 写入缓存
playlistDurCacheMu.Lock()
playlistDurCache[path] = &playlistDurCacheEntry{
mtime: fi.ModTime(),
size: fi.Size(),
durs: out,
}
playlistDurCacheMu.Unlock()
return out
}
// buildFFmpegArgs 构造 ffmpeg 命令行(一次性整段转码,兼容旧路径)
// 当 Probe.Duration 不可用时,LocalRunner.runJob 会回退到这条路径。
func buildFFmpegArgs(job *Job, profile Profile, segPattern, playlistPath string) []string {
segDur := setting.GetInt(conf.TranscodeSegmentDuration, 6)
// startTime=0, duration=0(=不限) 表示整段转码;startSeq=0
return buildFFmpegArgsCommon(job, profile, segDur, 0, 0, 0, segPattern, playlistPath)
}
// buildFFmpegChunkArgs 构造 chunk 模式的 ffmpeg 命令行
// startTime: 起始时间(秒);duration: 持续时长(秒,0=不限);startSeq: 切片起始编号
// chunk 模式与一次性整段共用底层参数构造,差异仅在 -ss/-t/-start_number。
func buildFFmpegChunkArgs(job *Job, profile Profile, segDur int, startTime, duration float64, startSeq int, segPattern, playlistPath string) []string {
return buildFFmpegArgsCommon(job, profile, segDur, startTime, duration, startSeq, segPattern, playlistPath)
}
// buildFFmpegArgsCommon 通用 ffmpeg 命令行构造
// 当 startTime > 0 或 duration > 0 时,启用"chunk 模式":使用 -ss / -t / -start_number 限定范围
// 关键:-ss 必须放在 -i 之前(input seek,快),且需配合 force_key_frames 让 chunk 起始处生成 IDR 帧
func buildFFmpegArgsCommon(job *Job, profile Profile, segDur int, startTime, duration float64, startSeq int, segPattern, playlistPath string) []string {
hw := strings.ToLower(profile.HWAccel)
args := []string{"-y", "-loglevel", "warning"}
// 输入侧硬件加速
switch hw {
case "nvenc":
args = append(args, "-hwaccel", "cuda")
case "qsv":
args = append(args, "-hwaccel", "qsv")
case "vaapi":
args = append(args, "-hwaccel", "vaapi", "-hwaccel_output_format", "vaapi", "-vaapi_device", "/dev/dri/renderD128")
case "amf":
args = append(args, "-hwaccel", "d3d11va")
case "videotoolbox":
args = append(args, "-hwaccel", "videotoolbox")
case "auto":
args = append(args, "-hwaccel", "auto")
}
// 【chunk 模式】input seek:放在 -i 之前,速度快,会按关键帧对齐
// 注意 -ss 在 -i 之前是 input seek(用容器索引快速跳转),但落点是关键帧;
// 我们通过 force_key_frames 保证每个 chunk 边界都有关键帧,所以这里精度足够。
if startTime > 0.001 {
args = append(args, "-ss", fmt.Sprintf("%.3f", startTime))
}
// 输入:先限定协议白名单(输入选项,必须在 -i 之前),再指定输入
args = append(args, "-protocol_whitelist", ProtocolWhitelistFor(job.SourceURL))
args = append(args, "-i", job.SourceURL)
// 【chunk 模式】限定时长(output side 更精确)
if duration > 0.001 {
args = append(args, "-t", fmt.Sprintf("%.3f", duration))
}
// 视频编码器
vcodec := pickEncoder(profile.VideoCodec, hw)
args = append(args, "-c:v", vcodec)
// 【关键】强制浏览器兼容的 profile/level/pix_fmt,必须与 master.m3u8 声明的
// CODECS="avc1.640028" (H.264 High Profile Level 4.0) 一致,否则 HLS.js 会触发
// bufferAppendError 死循环。
if strings.Contains(vcodec, "264") {
args = append(args, "-profile:v", "high", "-level:v", "4.0", "-pix_fmt", "yuv420p")
}
if profile.VideoBitrate != "" {
args = append(args, "-b:v", profile.VideoBitrate)
args = append(args, "-maxrate", profile.VideoBitrate, "-bufsize", profile.VideoBitrate)
}
if profile.Scale != "" {
// 软件 / 硬件缩放
switch hw {
case "vaapi":
args = append(args, "-vf", "scale_vaapi="+profile.Scale)
case "qsv":
args = append(args, "-vf", "scale_qsv="+profile.Scale)
default:
args = append(args, "-vf", "scale="+profile.Scale)
}
}
args = append(args, "-preset", defaultPresetFor(vcodec))
// 音频
// 【关键修复】不能使用 -c:a copy,因为浏览器 MSE 不支持 AC3/DTS/EAC3/TrueHD 等
// 常见于 mkv 的音频编码。直接 copy 会导致 SourceBuffer.appendBuffer 失败
// 触发 bufferAppendError 死循环。统一强制转 AAC 兼容所有浏览器。
ac := profile.AudioCodec
if ac == "" || ac == "copy" {
ac = "aac"
}
args = append(args, "-c:a", ac)
ab := profile.AudioBitrate
if ab == "" {
ab = "160k"
}
args = append(args, "-b:a", ab)
// 强制立体声 + 48kHz,避免 5.1 声道 / 高采样率导致的兼容问题
args = append(args, "-ac", "2", "-ar", "48000")
// HLS 输出
if segDur <= 0 {
segDur = setting.GetInt(conf.TranscodeSegmentDuration, 6)
}
// 【关键】强制关键帧对齐切片边界,确保每个切片以 IDR 帧开始可独立解码。
// 没有这些参数时,第一个切片可能不含 IDR 帧,导致 HLS.js bufferAppendError。
// fps 假设 25 (帧率未知时的保守值),gop = segDur * fps;force_key_frames 让
// ffmpeg 在每个切片边界强制插入关键帧。
gop := segDur * 25
args = append(args,
"-g", strconv.Itoa(gop),
"-keyint_min", strconv.Itoa(gop),
"-sc_threshold", "0",
"-force_key_frames", fmt.Sprintf("expr:gte(t,n_forced*%d)", segDur),
)
// 【chunk 模式 - 关键修复】input seek 后 ffmpeg 会把第一帧 PTS 重置为 0,
// 这会导致 chunk-30 的切片在播放器看来是 PTS=0~60s(而不是 1800~1860s),
// 进而触发 HLS.js bufferAppendError 或时间轴错位。
// 解决方法:用 -output_ts_offset 让生成的切片时间戳偏移到原始位置。
if startTime > 0.001 {
args = append(args, "-output_ts_offset", fmt.Sprintf("%.3f", startTime))
}
args = append(args,
"-f", "hls",
"-hls_time", strconv.Itoa(segDur),
"-hls_playlist_type", "vod",
"-hls_segment_filename", segPattern,
"-hls_flags", "independent_segments+temp_file",
)
// 【chunk 模式】指定起始切片编号,让全局切片编号连续
if startSeq > 0 {
args = append(args, "-start_number", strconv.Itoa(startSeq))
}
args = append(args, playlistPath)
return args
}
// pickEncoder 根据视频编码 + HW 类型选择 ffmpeg 编码器
func pickEncoder(codec, hw string) string {
codec = strings.ToLower(codec)
switch codec {
case "h264", "":
switch hw {
case "nvenc":
return "h264_nvenc"
case "qsv":
return "h264_qsv"
case "vaapi":
return "h264_vaapi"
case "amf":
return "h264_amf"
case "videotoolbox":
return "h264_videotoolbox"
default:
return "libx264"
}
case "hevc", "h265":
switch hw {
case "nvenc":
return "hevc_nvenc"
case "qsv":
return "hevc_qsv"
case "vaapi":
return "hevc_vaapi"
case "amf":
return "hevc_amf"
case "videotoolbox":
return "hevc_videotoolbox"
default:
return "libx265"
}
case "av1":
switch hw {
case "nvenc":
return "av1_nvenc"
case "qsv":
return "av1_qsv"
default:
return "libsvtav1"
}
}
return "libx264"
}
func defaultPresetFor(encoder string) string {
if strings.Contains(encoder, "nvenc") {
return "p5"
}
if strings.Contains(encoder, "qsv") {
return "medium"
}
if strings.Contains(encoder, "amf") {
return "balanced"
}
if strings.Contains(encoder, "videotoolbox") {
return "medium"
}
return "veryfast"
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "...(truncated)"
}
// ProbeSource 调用 ffprobe 探测源文件
func ProbeSource(url string) (SourceProbe, error) {
if !ValidSourceInput(url) {
return SourceProbe{}, fmt.Errorf("unsupported probe source")
}
probePath := setting.GetStr(conf.TranscodeFFprobePath, "ffprobe")
args := []string{
"-v", "error",
"-protocol_whitelist", ProtocolWhitelistFor(url),
"-show_entries", "format=duration,size,bit_rate:stream=codec_type,codec_name,bit_rate,width,height",
"-of", "json",
url,
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, probePath, args...)
if runtime.GOOS == "windows" {
// no special handling
}
out, err := cmd.Output()
if err != nil {
return SourceProbe{}, err
}
var raw struct {
Format struct {
Duration string `json:"duration"`
Size string `json:"size"`
BitRate string `json:"bit_rate"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
BitRate string `json:"bit_rate"`
Width int `json:"width"`
Height int `json:"height"`
} `json:"streams"`
}
if err := json.Unmarshal(out, &raw); err != nil {
return SourceProbe{}, err
}
p := SourceProbe{}
p.Duration, _ = strconv.ParseFloat(raw.Format.Duration, 64)
p.Size, _ = strconv.ParseInt(raw.Format.Size, 10, 64)
for _, s := range raw.Streams {
if s.CodecType == "video" && p.VideoCodec == "" {
p.VideoCodec = s.CodecName
p.Width = s.Width
p.Height = s.Height
p.VideoBitrate, _ = strconv.ParseInt(s.BitRate, 10, 64)
}
if s.CodecType == "audio" && p.AudioCodec == "" {
p.AudioCodec = s.CodecName
p.AudioBitrate, _ = strconv.ParseInt(s.BitRate, 10, 64)
}
}
return p, nil
}