mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-11-11 05:00:47 +08:00
* 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>
773 lines
23 KiB
Go
773 lines
23 KiB
Go
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
|
||
}
|