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>
521 lines
15 KiB
Go
521 lines
15 KiB
Go
// Package transcode chunk_manager.go:智能分段转码调度器
|
||
//
|
||
// 设计目标:
|
||
// 1. 把视频按时间切成多个 chunk(默认每 60 秒一段),每个 chunk 独立用 ffmpeg 转码
|
||
// 2. 用户拖动到任意位置时,按需启动对应 chunk 的 ffmpeg,不再线性等待
|
||
// 3. 控制单 Job 最多并发 N 个 chunk ffmpeg,超出时 LRU 淘汰最久未访问的 chunk
|
||
// 4. 当前 chunk 接近末尾时自动预启动下一个 chunk,保证顺序播放无缝衔接
|
||
// 5. chunk 空闲超过阈值时 kill ffmpeg 释放 CPU,但保留已转出的切片文件
|
||
//
|
||
// 与 LocalRunner 的关系:
|
||
// - LocalRunner.runJob 不再直接启动一个完整的 ffmpeg,而是创建 ChunkSession 并阻塞等待
|
||
// - ChunkManager 是 ChunkSession 的"大脑",负责调度
|
||
// - TCSegment 收到切片请求时,调用 ChunkManager.EnsureChunkRunningForSeg(seq) 触发按需调度
|
||
package transcode
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/OpenListTeam/OpenList/v4/internal/conf"
|
||
"github.com/OpenListTeam/OpenList/v4/internal/setting"
|
||
"github.com/OpenListTeam/OpenList/v4/pkg/utils"
|
||
)
|
||
|
||
// ChunkState chunk 当前状态
|
||
type ChunkState int
|
||
|
||
const (
|
||
ChunkIdle ChunkState = iota // 未启动
|
||
ChunkRunning // ffmpeg 正在转码
|
||
ChunkDone // ffmpeg 自然退出(chunk 内所有切片已转完)
|
||
ChunkFailed // ffmpeg 异常失败
|
||
ChunkKilled // 被 LRU 淘汰或空闲超时 kill
|
||
)
|
||
|
||
// Chunk 表示视频中的一个时间段,对应一段连续的切片序号
|
||
type Chunk struct {
|
||
Index int // chunk 序号(0 开始)
|
||
StartTime float64 // 起始秒
|
||
Duration float64 // 持续时长(最后一个 chunk 可能不足 chunkDur)
|
||
StartSeq int // 起始切片序号(包含)
|
||
EndSeq int // 结束切片序号(不包含)
|
||
State ChunkState
|
||
StartedAt time.Time
|
||
LastAccess time.Time
|
||
Error string
|
||
|
||
cancel context.CancelFunc // ffmpeg 进程的 cancel
|
||
doneCh chan struct{} // ffmpeg 进程结束时关闭
|
||
doneOnce sync.Once
|
||
}
|
||
|
||
// chunkDoneClose 关闭 doneCh(幂等)
|
||
func (c *Chunk) chunkDoneClose() {
|
||
c.doneOnce.Do(func() {
|
||
if c.doneCh != nil {
|
||
close(c.doneCh)
|
||
}
|
||
})
|
||
}
|
||
|
||
// ChunkSession 单个 (jobID, profile) 下的 chunk 调度上下文
|
||
type ChunkSession struct {
|
||
JobID string
|
||
Profile string
|
||
Job *Job
|
||
ProfDef Profile
|
||
|
||
// 静态参数
|
||
TotalDuration float64 // 视频总时长
|
||
SegDur int // 每片时长(秒)
|
||
ChunkDur int // 每个 chunk 时长(秒)
|
||
MaxConcurrent int // 最大并发 chunk 数
|
||
IdleSec int // chunk 空闲多久就 kill
|
||
Prefetch bool // 是否启用预读
|
||
|
||
mu sync.Mutex
|
||
chunks map[int]*Chunk // chunkIndex -> Chunk
|
||
|
||
// 给 LocalRunner 用:所有切片转完时关闭,让 runJob 能退出
|
||
allDoneCh chan struct{}
|
||
allDoneOnce sync.Once
|
||
|
||
// outDir / segPattern (供 ffmpeg 使用)
|
||
OutDir string
|
||
}
|
||
|
||
// allDoneClose 关闭 allDoneCh(幂等)
|
||
func (s *ChunkSession) allDoneClose() {
|
||
s.allDoneOnce.Do(func() {
|
||
close(s.allDoneCh)
|
||
})
|
||
}
|
||
|
||
// ChunkManager 全局 chunk 管理器(一个 Manager 一个)
|
||
type ChunkManager struct {
|
||
mgr *Manager
|
||
|
||
mu sync.Mutex
|
||
sessions map[string]*ChunkSession // key = jobID + ":" + profile
|
||
|
||
stopCh chan struct{}
|
||
}
|
||
|
||
// NewChunkManager 创建实例
|
||
func NewChunkManager(mgr *Manager) *ChunkManager {
|
||
return &ChunkManager{
|
||
mgr: mgr,
|
||
sessions: make(map[string]*ChunkSession),
|
||
stopCh: make(chan struct{}),
|
||
}
|
||
}
|
||
|
||
func sessionKey(jobID, profile string) string { return jobID + ":" + profile }
|
||
|
||
// StartSession 创建并启动一个 chunk session(由 LocalRunner.runJob 调用)
|
||
// 返回的 ChunkSession.allDoneCh 在所有切片都转完时关闭
|
||
func (cm *ChunkManager) StartSession(job *Job, profile Profile, outDir string) (*ChunkSession, error) {
|
||
totalDur := job.Probe.Duration
|
||
if totalDur <= 0 {
|
||
return nil, fmt.Errorf("chunk session requires probe.duration > 0")
|
||
}
|
||
segDur := setting.GetInt(conf.TranscodeSegmentDuration, 6)
|
||
if segDur <= 0 {
|
||
segDur = 6
|
||
}
|
||
chunkDur := setting.GetInt(conf.TranscodeChunkDurationSec, 60)
|
||
if chunkDur < segDur {
|
||
chunkDur = segDur * 10
|
||
}
|
||
// 让 chunkDur 是 segDur 的整数倍,保证切片对齐
|
||
if chunkDur%segDur != 0 {
|
||
chunkDur = (chunkDur / segDur) * segDur
|
||
if chunkDur < segDur {
|
||
chunkDur = segDur
|
||
}
|
||
}
|
||
maxConc := setting.GetInt(conf.TranscodeMaxChunkConcurrency, 2)
|
||
if maxConc <= 0 {
|
||
maxConc = 2
|
||
}
|
||
idleSec := setting.GetInt(conf.TranscodeChunkIdleSec, 60)
|
||
if idleSec <= 0 {
|
||
idleSec = 60
|
||
}
|
||
prefetch := setting.GetBool(conf.TranscodeChunkPrefetch)
|
||
|
||
s := &ChunkSession{
|
||
JobID: job.ID,
|
||
Profile: profile.Name,
|
||
Job: job,
|
||
ProfDef: profile,
|
||
TotalDuration: totalDur,
|
||
SegDur: segDur,
|
||
ChunkDur: chunkDur,
|
||
MaxConcurrent: maxConc,
|
||
IdleSec: idleSec,
|
||
Prefetch: prefetch,
|
||
chunks: make(map[int]*Chunk),
|
||
allDoneCh: make(chan struct{}),
|
||
OutDir: outDir,
|
||
}
|
||
|
||
// 预先按总时长生成所有 chunk 元数据(不启动)
|
||
segsPerChunk := chunkDur / segDur
|
||
totalSegs := int(totalDur) / segDur
|
||
if int(totalDur)%segDur > 0 {
|
||
totalSegs++
|
||
}
|
||
chunkCount := totalSegs / segsPerChunk
|
||
if totalSegs%segsPerChunk > 0 {
|
||
chunkCount++
|
||
}
|
||
for i := 0; i < chunkCount; i++ {
|
||
startSeq := i * segsPerChunk
|
||
endSeq := startSeq + segsPerChunk
|
||
if endSeq > totalSegs {
|
||
endSeq = totalSegs
|
||
}
|
||
startT := float64(startSeq * segDur)
|
||
dur := float64((endSeq - startSeq) * segDur)
|
||
// 最后一个 chunk 可能不足
|
||
if i == chunkCount-1 {
|
||
realDur := totalDur - startT
|
||
if realDur > 0 && realDur < dur {
|
||
dur = realDur
|
||
}
|
||
}
|
||
s.chunks[i] = &Chunk{
|
||
Index: i,
|
||
StartTime: startT,
|
||
Duration: dur,
|
||
StartSeq: startSeq,
|
||
EndSeq: endSeq,
|
||
State: ChunkIdle,
|
||
}
|
||
}
|
||
|
||
cm.mu.Lock()
|
||
cm.sessions[sessionKey(job.ID, profile.Name)] = s
|
||
cm.mu.Unlock()
|
||
|
||
// 立刻启动第一个 chunk(保证 playlist 首切片可用)
|
||
cm.ensureChunk(s, 0)
|
||
|
||
// 启动后台维护协程:空闲清理 + 完成检测
|
||
go cm.sessionLoop(s)
|
||
|
||
utils.Log.Infof("[transcode][chunk] session started job=%s profile=%s totalDur=%.1fs chunkDur=%ds chunks=%d maxConc=%d",
|
||
job.ID, profile.Name, totalDur, chunkDur, chunkCount, maxConc)
|
||
return s, nil
|
||
}
|
||
|
||
// EnsureChunkRunningForSeg 确保覆盖 seq 的 chunk 正在转码(由 TCSegment 触发)
|
||
// 返回所属 chunk index;如果 seq 越界返回 -1
|
||
func (cm *ChunkManager) EnsureChunkRunningForSeg(jobID, profile string, seq int) int {
|
||
cm.mu.Lock()
|
||
s, ok := cm.sessions[sessionKey(jobID, profile)]
|
||
cm.mu.Unlock()
|
||
if !ok {
|
||
utils.Log.Warnf("[transcode][chunk] EnsureChunk: session not found job=%s profile=%s seq=%d", jobID, profile, seq)
|
||
return -1
|
||
}
|
||
idx := cm.findChunkBySeq(s, seq)
|
||
if idx < 0 {
|
||
utils.Log.Warnf("[transcode][chunk] EnsureChunk: seq=%d out of range job=%s", seq, jobID)
|
||
return -1
|
||
}
|
||
cm.ensureChunk(s, idx)
|
||
return idx
|
||
}
|
||
|
||
// findChunkBySeq 按切片序号找到所属 chunk
|
||
func (cm *ChunkManager) findChunkBySeq(s *ChunkSession, seq int) int {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
for idx, c := range s.chunks {
|
||
if seq >= c.StartSeq && seq < c.EndSeq {
|
||
return idx
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
// ensureChunk 确保某个 chunk 处于 Running 状态(已运行/已完成则跳过)
|
||
// 同时受 MaxConcurrent 限制,超出时 LRU 淘汰最久未访问的 chunk
|
||
func (cm *ChunkManager) ensureChunk(s *ChunkSession, idx int) {
|
||
s.mu.Lock()
|
||
c, ok := s.chunks[idx]
|
||
if !ok {
|
||
s.mu.Unlock()
|
||
utils.Log.Warnf("[transcode][chunk] ensureChunk: chunk %d not in session (job=%s)", idx, s.JobID)
|
||
return
|
||
}
|
||
now := time.Now()
|
||
c.LastAccess = now
|
||
prevState := c.State
|
||
switch c.State {
|
||
case ChunkRunning:
|
||
s.mu.Unlock()
|
||
utils.Log.Debugf("[transcode][chunk] ensureChunk: chunk %d already running (job=%s)", idx, s.JobID)
|
||
return
|
||
case ChunkDone:
|
||
s.mu.Unlock()
|
||
utils.Log.Debugf("[transcode][chunk] ensureChunk: chunk %d already done (job=%s)", idx, s.JobID)
|
||
return
|
||
}
|
||
// 【关键修复】ChunkFailed 也允许重启:因为可能是网络抖动、source URL 临时失效等
|
||
// 而不是永久性故障;如果连续失败,runChunk 内部不会陷入死循环(每次都会重新尝试源)
|
||
// ChunkIdle / ChunkKilled / ChunkFailed → 需要启动
|
||
running := 0
|
||
var lruIdx = -1
|
||
var lruTime = now
|
||
for i, ck := range s.chunks {
|
||
if ck.State == ChunkRunning {
|
||
running++
|
||
if i != idx && ck.LastAccess.Before(lruTime) {
|
||
lruTime = ck.LastAccess
|
||
lruIdx = i
|
||
}
|
||
}
|
||
}
|
||
if running >= s.MaxConcurrent && lruIdx >= 0 {
|
||
victim := s.chunks[lruIdx]
|
||
utils.Log.Infof("[transcode][chunk] LRU evict chunk %d (job=%s) to make room for chunk %d",
|
||
lruIdx, s.JobID, idx)
|
||
if victim.cancel != nil {
|
||
victim.cancel()
|
||
}
|
||
victim.State = ChunkKilled
|
||
}
|
||
// 启动 chunk ffmpeg
|
||
c.State = ChunkRunning
|
||
c.StartedAt = now
|
||
c.doneCh = make(chan struct{})
|
||
c.doneOnce = sync.Once{}
|
||
s.mu.Unlock()
|
||
|
||
utils.Log.Infof("[transcode][chunk] ensureChunk: launching chunk %d (job=%s prevState=%v running=%d)",
|
||
idx, s.JobID, prevState, running)
|
||
go cm.runChunk(s, idx)
|
||
}
|
||
|
||
// runChunk 实际启动 ffmpeg 转码该 chunk
|
||
func (cm *ChunkManager) runChunk(s *ChunkSession, idx int) {
|
||
s.mu.Lock()
|
||
c := s.chunks[idx]
|
||
s.mu.Unlock()
|
||
if c == nil {
|
||
return
|
||
}
|
||
|
||
timeoutMin := setting.GetInt(conf.TranscodeJobTimeoutMin, 120)
|
||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMin)*time.Minute)
|
||
s.mu.Lock()
|
||
c.cancel = cancel
|
||
s.mu.Unlock()
|
||
defer cancel()
|
||
|
||
segPattern := filepath.Join(s.OutDir, "seg-%d.ts")
|
||
// 每个 chunk 写自己的 playlist 文件,防止互相覆盖;后端不读这个 playlist 用于响应,
|
||
// 真正的总 playlist 由 cache.BuildPlaylist 根据总时长生成。
|
||
chunkPlaylist := filepath.Join(s.OutDir, fmt.Sprintf("_chunk_%d.m3u8", idx))
|
||
|
||
args := buildFFmpegChunkArgs(s.Job, s.ProfDef, s.SegDur, c.StartTime, c.Duration, c.StartSeq, segPattern, chunkPlaylist)
|
||
ffmpegPath := setting.GetStr(conf.TranscodeFFmpegPath, "ffmpeg")
|
||
cmd := exec.CommandContext(ctx, ffmpegPath, args...)
|
||
stderr := &limitedBuffer{maxSize: 32 * 1024}
|
||
cmd.Stderr = stderr
|
||
|
||
utils.Log.Infof("[transcode][chunk] start ffmpeg job=%s chunk=%d ss=%.1f t=%.1f startSeq=%d",
|
||
s.JobID, idx, c.StartTime, c.Duration, c.StartSeq)
|
||
|
||
startedAt := time.Now()
|
||
err := cmd.Run()
|
||
elapsed := time.Since(startedAt)
|
||
|
||
s.mu.Lock()
|
||
switch {
|
||
case ctx.Err() != nil && c.State == ChunkKilled:
|
||
// 被 LRU 主动 kill,状态保持 Killed
|
||
utils.Log.Infof("[transcode][chunk] chunk %d killed (job=%s elapsed=%.1fs)", idx, s.JobID, elapsed.Seconds())
|
||
case err != nil:
|
||
c.State = ChunkFailed
|
||
c.Error = truncate(stderr.String(), 512)
|
||
utils.Log.Errorf("[transcode][chunk] chunk %d failed (job=%s elapsed=%.1fs): %v stderr=%s",
|
||
idx, s.JobID, elapsed.Seconds(), err, c.Error)
|
||
default:
|
||
c.State = ChunkDone
|
||
utils.Log.Infof("[transcode][chunk] chunk %d done (job=%s elapsed=%.1fs)", idx, s.JobID, elapsed.Seconds())
|
||
}
|
||
c.chunkDoneClose()
|
||
s.mu.Unlock()
|
||
|
||
// 删除临时 playlist 文件
|
||
_ = os.Remove(chunkPlaylist)
|
||
|
||
// 【关键修复】chunk 自然完成时,必须用 finalScan-style 扫描,否则边界切片(最大 seq)
|
||
// 永远不会被登记到 cache,导致下次播放/拖动到该位置时 60 秒超时
|
||
if cm.mgr != nil && cm.mgr.localRunner != nil {
|
||
cm.mgr.localRunner.publishChunkSegments(s.Job, s.ProfDef, s.OutDir, c.StartSeq, c.EndSeq)
|
||
}
|
||
}
|
||
|
||
// sessionLoop 每个 session 的后台维护:空闲清理 + 全部完成检测 + 预读
|
||
func (cm *ChunkManager) sessionLoop(s *ChunkSession) {
|
||
t := time.NewTicker(5 * time.Second)
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-cm.stopCh:
|
||
cm.killAllChunks(s)
|
||
return
|
||
case <-s.allDoneCh:
|
||
return
|
||
case <-t.C:
|
||
cm.maintainSession(s)
|
||
}
|
||
}
|
||
}
|
||
|
||
// maintainSession 一次维护:检查空闲、预读下一 chunk、判断是否全部完成
|
||
func (cm *ChunkManager) maintainSession(s *ChunkSession) {
|
||
now := time.Now()
|
||
idleThreshold := time.Duration(s.IdleSec) * time.Second
|
||
|
||
s.mu.Lock()
|
||
// 1. 空闲清理:Running 状态但长时间未访问 → kill
|
||
for _, c := range s.chunks {
|
||
if c.State == ChunkRunning && now.Sub(c.LastAccess) > idleThreshold {
|
||
utils.Log.Infof("[transcode][chunk] chunk %d idle for %.0fs, killing (job=%s)",
|
||
c.Index, now.Sub(c.LastAccess).Seconds(), s.JobID)
|
||
if c.cancel != nil {
|
||
c.cancel()
|
||
}
|
||
c.State = ChunkKilled
|
||
}
|
||
}
|
||
// 2. 预读:找出最近被访问的 Running chunk,如果它的进度过半,启动下一个 chunk
|
||
var prefetchTarget = -1
|
||
if s.Prefetch {
|
||
var hottestIdx = -1
|
||
var hottestAccess time.Time
|
||
for i, c := range s.chunks {
|
||
if c.State == ChunkRunning && c.LastAccess.After(hottestAccess) {
|
||
hottestAccess = c.LastAccess
|
||
hottestIdx = i
|
||
}
|
||
}
|
||
if hottestIdx >= 0 {
|
||
hot := s.chunks[hottestIdx]
|
||
// 估算进度:用 ffmpeg 已经写出的切片数 / 该 chunk 总切片数
|
||
// 由于 cache.segments 是全局的,我们检查范围 [StartSeq, EndSeq) 中已就绪数量
|
||
done := cm.countReadySegments(s, hot.StartSeq, hot.EndSeq)
|
||
expected := hot.EndSeq - hot.StartSeq
|
||
if expected > 0 && done*2 >= expected { // 进度 >= 50%
|
||
next := hottestIdx + 1
|
||
if nc, ok := s.chunks[next]; ok && nc.State == ChunkIdle {
|
||
prefetchTarget = next
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 3. 全部完成检测:所有 chunk 都 Done
|
||
allDone := true
|
||
for _, c := range s.chunks {
|
||
if c.State != ChunkDone {
|
||
allDone = false
|
||
break
|
||
}
|
||
}
|
||
s.mu.Unlock()
|
||
|
||
if prefetchTarget >= 0 {
|
||
utils.Log.Infof("[transcode][chunk] prefetch chunk %d (job=%s)", prefetchTarget, s.JobID)
|
||
cm.ensureChunk(s, prefetchTarget)
|
||
}
|
||
|
||
if allDone {
|
||
s.allDoneClose()
|
||
}
|
||
}
|
||
|
||
// countReadySegments 统计 [start, end) 范围内已就绪的切片数
|
||
func (cm *ChunkManager) countReadySegments(s *ChunkSession, start, end int) int {
|
||
if cm.mgr == nil || cm.mgr.Cache == nil {
|
||
return 0
|
||
}
|
||
cnt := 0
|
||
for seq := start; seq < end; seq++ {
|
||
if _, err := cm.mgr.Cache.GetSegment(s.JobID, s.Profile, seq); err == nil {
|
||
cnt++
|
||
}
|
||
}
|
||
return cnt
|
||
}
|
||
|
||
// killAllChunks 关闭某 session 下所有运行中的 ffmpeg
|
||
func (cm *ChunkManager) killAllChunks(s *ChunkSession) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
for _, c := range s.chunks {
|
||
if c.State == ChunkRunning && c.cancel != nil {
|
||
c.cancel()
|
||
c.State = ChunkKilled
|
||
}
|
||
}
|
||
}
|
||
|
||
// CancelSession 外部取消(job 被 cancel 或 idle 超时)
|
||
func (cm *ChunkManager) CancelSession(jobID, profile string) {
|
||
cm.mu.Lock()
|
||
s, ok := cm.sessions[sessionKey(jobID, profile)]
|
||
if ok {
|
||
delete(cm.sessions, sessionKey(jobID, profile))
|
||
}
|
||
cm.mu.Unlock()
|
||
if !ok {
|
||
return
|
||
}
|
||
cm.killAllChunks(s)
|
||
s.allDoneClose()
|
||
}
|
||
|
||
// CancelAllForJob 取消该 job 下所有 profile 的 session(CancelJob 时调用)
|
||
func (cm *ChunkManager) CancelAllForJob(jobID string) {
|
||
cm.mu.Lock()
|
||
var targets []*ChunkSession
|
||
for k, s := range cm.sessions {
|
||
if s.JobID == jobID {
|
||
targets = append(targets, s)
|
||
delete(cm.sessions, k)
|
||
}
|
||
}
|
||
cm.mu.Unlock()
|
||
for _, s := range targets {
|
||
cm.killAllChunks(s)
|
||
s.allDoneClose()
|
||
}
|
||
}
|
||
|
||
// WaitDone 阻塞等待所有 chunk 完成(LocalRunner.runJob 用)
|
||
func (s *ChunkSession) WaitDone() <-chan struct{} { return s.allDoneCh }
|
||
|
||
// Stop 停止 ChunkManager(Manager.Stop 调用)
|
||
func (cm *ChunkManager) Stop() {
|
||
close(cm.stopCh)
|
||
cm.mu.Lock()
|
||
defer cm.mu.Unlock()
|
||
for _, s := range cm.sessions {
|
||
cm.killAllChunks(s)
|
||
s.allDoneClose()
|
||
}
|
||
cm.sessions = nil
|
||
}
|