mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-11-13 06: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>
461 lines
12 KiB
Go
461 lines
12 KiB
Go
package transcode
|
||
|
||
import (
|
||
"errors"
|
||
"sort"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Scheduler 任务调度器:维护 pending 队列并支持 worker pull 领取
|
||
type Scheduler struct {
|
||
mu sync.Mutex
|
||
jobs map[string]*Job // 全部任务(含完成)
|
||
pending []*Job // 待领取队列
|
||
|
||
// 等待新任务的 worker 通知
|
||
waiters []chan struct{}
|
||
|
||
// cancellation 待派发给 worker 的取消列表(worker_id -> job_ids)
|
||
pendingCancels map[string][]string
|
||
|
||
// onCancel 回调:Cancel 时同步通知本地 runner kill FFmpeg 进程
|
||
// 由 Manager 在启动时注入
|
||
onCancel func(jobID string)
|
||
|
||
registry *WorkerRegistry
|
||
}
|
||
|
||
func NewScheduler(reg *WorkerRegistry) *Scheduler {
|
||
return &Scheduler{
|
||
jobs: make(map[string]*Job),
|
||
pendingCancels: make(map[string][]string),
|
||
registry: reg,
|
||
}
|
||
}
|
||
|
||
// SetOnCancel 注入取消回调(由 Manager 在启动本地 worker 后调用)
|
||
func (s *Scheduler) SetOnCancel(fn func(jobID string)) {
|
||
s.mu.Lock()
|
||
s.onCancel = fn
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// 提交配额。转码是 CPU/磁盘密集型操作,必须限制单用户并发与全局排队长度,
|
||
// 否则任何能访问播放接口的用户都能无限堆积任务(每个任务还会触发 30s 的 ffprobe)。
|
||
const (
|
||
// MaxPendingJobs 全局排队(未被 worker 领取)任务上限
|
||
MaxPendingJobs = 200
|
||
// MaxActiveJobsPerUser 单用户同时存在的未结束任务上限
|
||
MaxActiveJobsPerUser = 3
|
||
)
|
||
|
||
var (
|
||
// ErrQueueFull 全局队列已满
|
||
ErrQueueFull = errors.New("transcode queue is full, try again later")
|
||
// ErrUserQuota 单用户并发任务已达上限
|
||
ErrUserQuota = errors.New("too many active transcode jobs for this user")
|
||
// ErrJobNotFound 任务不存在或已被清理
|
||
ErrJobNotFound = errors.New("transcode job not found")
|
||
)
|
||
|
||
// isActiveStatus 任务是否仍占用资源(未结束)
|
||
func isActiveStatus(st JobStatus) bool {
|
||
return st == JobPending || st == JobRunning || st == JobReady
|
||
}
|
||
|
||
// FindReusable 查找同一用户对同一路径、仍在进行中的任务,用于去重复用。
|
||
func (s *Scheduler) FindReusable(ownerID string, path string) (*Job, bool) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.findReusableLocked(ownerID, path)
|
||
}
|
||
|
||
func (s *Scheduler) findReusableLocked(ownerID string, path string) (*Job, bool) {
|
||
for _, j := range s.jobs {
|
||
if j.OwnerID == ownerID && j.Path == path && isActiveStatus(j.GetStatus()) {
|
||
return j, true
|
||
}
|
||
}
|
||
return nil, false
|
||
}
|
||
|
||
// SubmitLimited 带去重与配额的提交:
|
||
// 1. 同一用户对同一路径已有进行中的任务时直接复用,不重复入队;
|
||
// 2. 全局排队过长或用户任务过多时拒绝。
|
||
//
|
||
// 返回 (job, reused, err)。
|
||
func (s *Scheduler) SubmitLimited(j *Job) (*Job, bool, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if exist, ok := s.findReusableLocked(j.OwnerID, j.Path); ok {
|
||
return exist, true, nil
|
||
}
|
||
if len(s.pending) >= MaxPendingJobs {
|
||
return nil, false, ErrQueueFull
|
||
}
|
||
active := 0
|
||
for _, x := range s.jobs {
|
||
if x.OwnerID == j.OwnerID && isActiveStatus(x.GetStatus()) {
|
||
active++
|
||
}
|
||
}
|
||
if active >= MaxActiveJobsPerUser {
|
||
return nil, false, ErrUserQuota
|
||
}
|
||
// 检查与入队在同一把锁内完成,避免并发请求同时越过配额
|
||
return s.enqueueLocked(j), false, nil
|
||
}
|
||
|
||
// Capacity 只读预检:在启动 ffprobe 这类昂贵操作之前先看配额是否已满。
|
||
//
|
||
// 真正的配额判定仍由 SubmitLimited 在同一把锁内完成;这里的目的有两个:
|
||
// 1. 不让用户先等一次最长 30s 的 ffprobe 才收到 429;
|
||
// 2. 把「并发请求同时通过配额判定」这个窗口内的探测数量压下来。
|
||
func (s *Scheduler) Capacity(ownerID string) error {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if len(s.pending) >= MaxPendingJobs {
|
||
return ErrQueueFull
|
||
}
|
||
active := 0
|
||
for _, x := range s.jobs {
|
||
if x.OwnerID == ownerID && isActiveStatus(x.GetStatus()) {
|
||
active++
|
||
}
|
||
}
|
||
if active >= MaxActiveJobsPerUser {
|
||
return ErrUserQuota
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Submit 提交一个任务并返回(带 ID + token)。不做配额限制,仅供内部流程(如重新激活)使用。
|
||
func (s *Scheduler) Submit(j *Job) *Job {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
return s.enqueueLocked(j)
|
||
}
|
||
|
||
// enqueueLocked 补全任务字段并入队,调用方必须已持有 s.mu。
|
||
func (s *Scheduler) enqueueLocked(j *Job) *Job {
|
||
if j.ID == "" {
|
||
j.ID = "job_" + uuid.NewString()
|
||
}
|
||
if j.CallbackToken == "" {
|
||
j.CallbackToken = "tk_" + uuid.NewString()
|
||
}
|
||
// 播放凭据与 Worker 回写凭据分离:播放端只能读,不能上传切片或结束任务
|
||
if j.PlayToken == "" {
|
||
j.PlayToken = "pt_" + uuid.NewString()
|
||
}
|
||
if j.readyCh == nil {
|
||
j.readyCh = make(chan struct{})
|
||
}
|
||
if j.CreatedAt.IsZero() {
|
||
j.CreatedAt = time.Now()
|
||
}
|
||
j.Status = JobPending
|
||
|
||
s.jobs[j.ID] = j
|
||
s.pending = append(s.pending, j)
|
||
// 唤醒等待的 worker
|
||
for _, ch := range s.waiters {
|
||
close(ch)
|
||
}
|
||
s.waiters = nil
|
||
return j
|
||
}
|
||
|
||
// Get 通过 id 获取任务
|
||
func (s *Scheduler) Get(id string) (*Job, bool) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
j, ok := s.jobs[id]
|
||
return j, ok
|
||
}
|
||
|
||
// Claim 由 worker 调用领取任务(最多 slots 个);如果没有任务则等待 wait 秒
|
||
func (s *Scheduler) Claim(workerID string, slots int, wait time.Duration) []*Job {
|
||
worker, ok := s.registry.Get(workerID)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
taken := s.claimNow(worker, slots)
|
||
if len(taken) > 0 || wait <= 0 {
|
||
return taken
|
||
}
|
||
// 长轮询等待
|
||
s.mu.Lock()
|
||
notify := make(chan struct{})
|
||
s.waiters = append(s.waiters, notify)
|
||
s.mu.Unlock()
|
||
select {
|
||
case <-notify:
|
||
case <-time.After(wait):
|
||
}
|
||
return s.claimNow(worker, slots)
|
||
}
|
||
|
||
func (s *Scheduler) claimNow(worker *Worker, slots int) []*Job {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if slots <= 0 || len(s.pending) == 0 {
|
||
return nil
|
||
}
|
||
// 按打分排序候选任务,选最匹配的前 slots 个
|
||
candidates := append([]*Job(nil), s.pending...)
|
||
sort.SliceStable(candidates, func(i, j int) bool {
|
||
return scoreJobForWorker(candidates[i], worker) > scoreJobForWorker(candidates[j], worker)
|
||
})
|
||
var taken []*Job
|
||
for _, j := range candidates {
|
||
if len(taken) >= slots {
|
||
break
|
||
}
|
||
if !workerCanRun(worker, j) {
|
||
continue
|
||
}
|
||
j.mu.Lock()
|
||
j.Status = JobRunning
|
||
j.WorkerID = worker.ID
|
||
j.StartedAt = time.Now()
|
||
j.mu.Unlock()
|
||
taken = append(taken, j)
|
||
// 从 pending 中移除
|
||
for idx, pj := range s.pending {
|
||
if pj == j {
|
||
s.pending = append(s.pending[:idx], s.pending[idx+1:]...)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return taken
|
||
}
|
||
|
||
// scoreJobForWorker 给 (worker, job) 打分(数值越大越匹配)
|
||
func scoreJobForWorker(j *Job, w *Worker) float64 {
|
||
score := 1.0
|
||
// 硬件加速匹配 + 50
|
||
if len(j.Profiles) > 0 {
|
||
req := strings.ToLower(j.Profiles[0].HWAccel)
|
||
if req != "" && req != "none" && req != "auto" {
|
||
for _, hw := range w.HWAccel {
|
||
if strings.EqualFold(hw, req) {
|
||
score += 50
|
||
break
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 空闲槽位多 +
|
||
score += float64(w.FreeSlots) * 2
|
||
// 负载低 +
|
||
score += (1 - w.Load) * 10
|
||
return score
|
||
}
|
||
|
||
// workerCanRun 检查 worker 是否能运行该 job
|
||
func workerCanRun(w *Worker, j *Job) bool {
|
||
if w.FreeSlots <= 0 {
|
||
return false
|
||
}
|
||
if j.Probe.VideoCodec != "" && len(w.CodecsDecode) > 0 {
|
||
ok := false
|
||
for _, c := range w.CodecsDecode {
|
||
if strings.EqualFold(c, j.Probe.VideoCodec) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// Finish 由 worker 调用,标记任务完成/失败
|
||
func (s *Scheduler) Finish(req *FinishRequest) error {
|
||
s.mu.Lock()
|
||
j, ok := s.jobs[req.JobID]
|
||
s.mu.Unlock()
|
||
if !ok {
|
||
return errors.New("job not found")
|
||
}
|
||
j.mu.Lock()
|
||
defer j.mu.Unlock()
|
||
j.Status = req.Status
|
||
j.Error = req.Error
|
||
j.FinishedAt = time.Now()
|
||
// 任务一旦完成或失败,确保 readyCh 一定会被关闭,避免播放端死等
|
||
j.readyOnce.Do(func() { close(j.readyCh) })
|
||
return nil
|
||
}
|
||
|
||
// Cancel 主动取消任务(同时通知对应 worker 并 kill 本地 FFmpeg 进程)
|
||
func (s *Scheduler) Cancel(jobID string) {
|
||
s.mu.Lock()
|
||
j, ok := s.jobs[jobID]
|
||
if !ok {
|
||
s.mu.Unlock()
|
||
return
|
||
}
|
||
j.mu.Lock()
|
||
if j.Status == JobPending || j.Status == JobRunning || j.Status == JobReady {
|
||
j.Status = JobCancelled
|
||
}
|
||
j.readyOnce.Do(func() { close(j.readyCh) })
|
||
wid := j.WorkerID
|
||
j.mu.Unlock()
|
||
if wid != "" {
|
||
s.pendingCancels[wid] = append(s.pendingCancels[wid], jobID)
|
||
}
|
||
// 从 pending 中剔除
|
||
for idx, pj := range s.pending {
|
||
if pj.ID == jobID {
|
||
s.pending = append(s.pending[:idx], s.pending[idx+1:]...)
|
||
break
|
||
}
|
||
}
|
||
cancelFn := s.onCancel
|
||
s.mu.Unlock()
|
||
// 在锁外调用回调,避免死锁;回调会 kill 本地 FFmpeg 进程
|
||
if cancelFn != nil {
|
||
cancelFn(jobID)
|
||
}
|
||
}
|
||
|
||
// ListActiveJobs 列出所有活跃任务(pending/running/ready),用于空闲超时检测
|
||
func (s *Scheduler) ListActiveJobs() []*Job {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
var out []*Job
|
||
for _, j := range s.jobs {
|
||
st := j.GetStatus()
|
||
if st == JobRunning || st == JobReady || st == JobPending {
|
||
out = append(out, j)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// PopCancellations 取出某个 worker 待处理的取消列表(心跳响应中下发)
|
||
func (s *Scheduler) PopCancellations(workerID string) []string {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
ids := s.pendingCancels[workerID]
|
||
delete(s.pendingCancels, workerID)
|
||
return ids
|
||
}
|
||
|
||
// ListJobs 列出全部任务(仅用于调试/管理)
|
||
func (s *Scheduler) ListJobs() []*Job {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
out := make([]*Job, 0, len(s.jobs))
|
||
for _, j := range s.jobs {
|
||
out = append(out, j)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// Reactivate 把一个已 Cancelled / Failed / Finished 的 job 重新激活回 Pending 状态,
|
||
// 让 worker 能再次领取并跑转码。用于"播放端重新请求时按需重启转码"的场景。
|
||
//
|
||
// 设计点:
|
||
// 1. 复用原 jobID 和两套 token(播放用 PlayToken / Worker 回写用 CallbackToken),
|
||
// 前端持有的 master.m3u8 中的 URL 不需要变更
|
||
// 2. 重置 readyCh / Status / WorkerID 等运行时字段,但保留 Path / SourceURL / Profiles / Probe
|
||
// 3. 重新加入 pending 队列并唤醒等待的 worker
|
||
//
|
||
// 返回被激活的 Job;job 不存在返回 ErrJobNotFound,单用户配额不足返回 ErrUserQuota,
|
||
// 全局排队已满返回 ErrQueueFull。
|
||
func (s *Scheduler) Reactivate(jobID string) (*Job, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
j, ok := s.jobs[jobID]
|
||
if !ok {
|
||
return nil, ErrJobNotFound
|
||
}
|
||
// 已经活跃:无需重新入队
|
||
if isActiveStatus(j.GetStatus()) {
|
||
return j, nil
|
||
}
|
||
|
||
// 重新激活同样受全局排队上限约束:否则可以靠「先让任务 idle 取消、再批量激活」
|
||
// 把 pending 队列顶到 MaxPendingJobs 之外。
|
||
if len(s.pending) >= MaxPendingJobs {
|
||
return nil, ErrQueueFull
|
||
}
|
||
|
||
// 重新激活同样占用配额。否则持有 PlayToken 的用户可以先让任务因 idle 被取消,
|
||
// 再统一请求 playlist / seg-N.ts 把它们一起重新激活,绕过 SubmitLimited 的单用户上限。
|
||
active := 0
|
||
for _, x := range s.jobs {
|
||
if x == j {
|
||
continue
|
||
}
|
||
if x.OwnerID == j.OwnerID && isActiveStatus(x.GetStatus()) {
|
||
active++
|
||
}
|
||
}
|
||
if active >= MaxActiveJobsPerUser {
|
||
return nil, ErrUserQuota
|
||
}
|
||
|
||
j.mu.Lock()
|
||
// 期间可能已被其它请求激活
|
||
if isActiveStatus(j.Status) {
|
||
j.mu.Unlock()
|
||
return j, nil
|
||
}
|
||
// 重置运行时状态
|
||
j.Status = JobPending
|
||
j.WorkerID = ""
|
||
j.Error = ""
|
||
j.FinishedAt = time.Time{}
|
||
j.LastAccessAt = time.Now()
|
||
// 重置 readyCh:原 channel 已 closed,需要全新一份给后续 WaitReady
|
||
j.readyCh = make(chan struct{})
|
||
j.readyOnce = sync.Once{}
|
||
j.mu.Unlock()
|
||
|
||
// 重新入 pending 队列(先确认不在队列中以免重复)
|
||
inQueue := false
|
||
for _, pj := range s.pending {
|
||
if pj.ID == jobID {
|
||
inQueue = true
|
||
break
|
||
}
|
||
}
|
||
if !inQueue {
|
||
s.pending = append(s.pending, j)
|
||
}
|
||
// 唤醒等待的 worker
|
||
for _, ch := range s.waiters {
|
||
close(ch)
|
||
}
|
||
s.waiters = nil
|
||
return j, nil
|
||
}
|
||
|
||
// PurgeBefore 删除 finishedAt 早于某时刻的任务,避免 jobs map 无限增长
|
||
// 返回被清理的 job ID 列表,调用方需联动清理 Cache
|
||
func (s *Scheduler) PurgeBefore(t time.Time) []string {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
var purged []string
|
||
for id, j := range s.jobs {
|
||
if (j.Status == JobFinished || j.Status == JobFailed || j.Status == JobCancelled) &&
|
||
!j.FinishedAt.IsZero() && j.FinishedAt.Before(t) {
|
||
delete(s.jobs, id)
|
||
purged = append(purged, id)
|
||
}
|
||
}
|
||
return purged
|
||
}
|