Files
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

521 lines
15 KiB
Go
Raw Permalink 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 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
}