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>
581 lines
15 KiB
Go
581 lines
15 KiB
Go
// Command openlist-transcode-worker 是独立的远程转码 Worker 节点。
|
||
// 它通过 OpenList 自研协议(HTTP/JSON)注册到 Master,长轮询领任务,调用
|
||
// 本机 ffmpeg 转码,并把生成的 HLS 切片实时上传回 Master。
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"flag"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"os/exec"
|
||
"os/signal"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
)
|
||
|
||
var (
|
||
masterURL = flag.String("master", "http://127.0.0.1:5244", "OpenList Master URL")
|
||
secret = flag.String("secret", "", "Worker 共享密钥(与 Master 配置一致)")
|
||
name = flag.String("name", hostname(), "Worker 名称")
|
||
capacity = flag.Int("capacity", 2, "并发任务数")
|
||
hwaccel = flag.String("hwaccel", "none", "硬件加速:none/auto/nvenc/qsv/vaapi/amf/videotoolbox")
|
||
workdir = flag.String("workdir", "./worker_tmp", "临时工作目录")
|
||
ffmpeg = flag.String("ffmpeg", "ffmpeg", "ffmpeg 路径")
|
||
)
|
||
|
||
func hostname() string {
|
||
h, _ := os.Hostname()
|
||
if h == "" {
|
||
h = "worker"
|
||
}
|
||
return h
|
||
}
|
||
|
||
// ---- 协议结构 ----(与 Master 端 internal/transcode/protocol.go 一致)
|
||
|
||
type RegisterReq struct {
|
||
Name string `json:"name"`
|
||
Version string `json:"version"`
|
||
Capacity int `json:"capacity"`
|
||
HWAccel []string `json:"hwaccel"`
|
||
CodecsDecode []string `json:"codecs_decode"`
|
||
CodecsEncode []string `json:"codecs_encode"`
|
||
MaxResolution string `json:"max_resolution"`
|
||
Tags []string `json:"tags"`
|
||
}
|
||
type RegisterResp struct {
|
||
Data struct {
|
||
WorkerID string `json:"worker_id"`
|
||
HeartbeatInterval int `json:"heartbeat_interval"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
type HeartbeatReq struct {
|
||
WorkerID string `json:"worker_id"`
|
||
Load float64 `json:"load"`
|
||
Running []string `json:"running"`
|
||
FreeSlots int `json:"free_slots"`
|
||
}
|
||
type HeartbeatResp struct {
|
||
Data struct {
|
||
OK bool `json:"ok"`
|
||
Kick bool `json:"kick"`
|
||
Cancel []string `json:"cancel"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
type ClaimReq struct {
|
||
WorkerID string `json:"worker_id"`
|
||
Slots int `json:"slots"`
|
||
Wait int `json:"wait"`
|
||
}
|
||
|
||
type Profile struct {
|
||
Name string `json:"name"`
|
||
VideoCodec string `json:"video_codec"`
|
||
VideoBitrate string `json:"video_bitrate"`
|
||
Scale string `json:"scale"`
|
||
AudioCodec string `json:"audio_codec"`
|
||
AudioBitrate string `json:"audio_bitrate"`
|
||
HWAccel string `json:"hwaccel"`
|
||
}
|
||
type OutputSpec struct {
|
||
Format string `json:"format"`
|
||
SegmentDuration int `json:"segment_duration"`
|
||
}
|
||
type Job struct {
|
||
ID string `json:"id"`
|
||
Path string `json:"path"`
|
||
SourceURL string `json:"source_url"`
|
||
Profiles []Profile `json:"profiles"`
|
||
Output OutputSpec `json:"output"`
|
||
CallbackToken string `json:"callback_token"`
|
||
Deadline int64 `json:"deadline"`
|
||
}
|
||
type ClaimResp struct {
|
||
Data struct {
|
||
Jobs []Job `json:"jobs"`
|
||
} `json:"data"`
|
||
}
|
||
|
||
type FinishReq struct {
|
||
JobID string `json:"job_id"`
|
||
Status string `json:"status"`
|
||
Error string `json:"error,omitempty"`
|
||
Stats struct {
|
||
Elapsed float64 `json:"elapsed"`
|
||
BytesOut int64 `json:"bytes_out"`
|
||
} `json:"stats"`
|
||
}
|
||
|
||
// ---- HTTP helper ----
|
||
|
||
var httpClient = &http.Client{Timeout: 60 * time.Second}
|
||
|
||
func postJSON(path string, body any, out any, bearer string) error {
|
||
buf, _ := json.Marshal(body)
|
||
req, _ := http.NewRequest(http.MethodPost, *masterURL+path, bytes.NewReader(buf))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
if bearer != "" {
|
||
req.Header.Set("Authorization", "Bearer "+bearer)
|
||
}
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
|
||
}
|
||
if out != nil {
|
||
return json.NewDecoder(resp.Body).Decode(out)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- main ----
|
||
|
||
func main() {
|
||
flag.Parse()
|
||
if *secret == "" {
|
||
fmt.Fprintln(os.Stderr, "missing -secret")
|
||
os.Exit(1)
|
||
}
|
||
if err := os.MkdirAll(*workdir, 0o755); err != nil {
|
||
fmt.Fprintln(os.Stderr, err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
workerID, hbInterval, err := registerOnce()
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "register failed:", err)
|
||
os.Exit(1)
|
||
}
|
||
fmt.Printf("[worker] registered: id=%s heartbeat=%ds\n", workerID, hbInterval)
|
||
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
sigCh := make(chan os.Signal, 1)
|
||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||
go func() {
|
||
<-sigCh
|
||
fmt.Println("[worker] shutting down")
|
||
cancel()
|
||
}()
|
||
|
||
state := &workerState{
|
||
id: workerID,
|
||
runningMu: sync.Mutex{},
|
||
running: map[string]context.CancelFunc{},
|
||
}
|
||
|
||
// 心跳协程
|
||
go func() {
|
||
t := time.NewTicker(time.Duration(hbInterval) * time.Second)
|
||
defer t.Stop()
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-t.C:
|
||
state.heartbeat()
|
||
}
|
||
}
|
||
}()
|
||
// 工作协程
|
||
for i := 0; i < *capacity; i++ {
|
||
go state.workLoop(ctx)
|
||
}
|
||
<-ctx.Done()
|
||
time.Sleep(500 * time.Millisecond)
|
||
}
|
||
|
||
func registerOnce() (string, int, error) {
|
||
req := RegisterReq{
|
||
Name: *name,
|
||
Version: "1.0.0",
|
||
Capacity: *capacity,
|
||
HWAccel: []string{*hwaccel},
|
||
CodecsDecode: []string{"h264", "hevc", "av1", "vp9", "mpeg4", "mpeg2video"},
|
||
CodecsEncode: []string{"h264", "hevc"},
|
||
MaxResolution: "3840x2160",
|
||
Tags: []string{runtime.GOOS, runtime.GOARCH},
|
||
}
|
||
var resp RegisterResp
|
||
if err := postJSON("/api/transcode/worker/register", req, &resp, *secret); err != nil {
|
||
return "", 0, err
|
||
}
|
||
if resp.Data.WorkerID == "" {
|
||
return "", 0, fmt.Errorf("master returned empty worker_id")
|
||
}
|
||
if resp.Data.HeartbeatInterval == 0 {
|
||
resp.Data.HeartbeatInterval = 10
|
||
}
|
||
return resp.Data.WorkerID, resp.Data.HeartbeatInterval, nil
|
||
}
|
||
|
||
// ---- 状态机 ----
|
||
|
||
type workerState struct {
|
||
id string
|
||
runningMu sync.Mutex
|
||
running map[string]context.CancelFunc
|
||
}
|
||
|
||
func (s *workerState) heartbeat() {
|
||
s.runningMu.Lock()
|
||
ids := make([]string, 0, len(s.running))
|
||
for id := range s.running {
|
||
ids = append(ids, id)
|
||
}
|
||
free := *capacity - len(ids)
|
||
s.runningMu.Unlock()
|
||
var resp HeartbeatResp
|
||
if err := postJSON("/api/transcode/worker/heartbeat", HeartbeatReq{
|
||
WorkerID: s.id,
|
||
Running: ids,
|
||
FreeSlots: free,
|
||
}, &resp, *secret); err != nil {
|
||
fmt.Println("[worker] heartbeat error:", err)
|
||
return
|
||
}
|
||
for _, jid := range resp.Data.Cancel {
|
||
s.runningMu.Lock()
|
||
if cancel, ok := s.running[jid]; ok {
|
||
cancel()
|
||
}
|
||
s.runningMu.Unlock()
|
||
}
|
||
}
|
||
|
||
func (s *workerState) workLoop(ctx context.Context) {
|
||
for {
|
||
if ctx.Err() != nil {
|
||
return
|
||
}
|
||
var resp ClaimResp
|
||
err := postJSON("/api/transcode/worker/claim", ClaimReq{
|
||
WorkerID: s.id, Slots: 1, Wait: 20,
|
||
}, &resp, *secret)
|
||
if err != nil {
|
||
fmt.Println("[worker] claim error:", err)
|
||
time.Sleep(5 * time.Second)
|
||
continue
|
||
}
|
||
if len(resp.Data.Jobs) == 0 {
|
||
continue
|
||
}
|
||
for _, j := range resp.Data.Jobs {
|
||
s.runJob(ctx, j)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *workerState) runJob(parent context.Context, job Job) {
|
||
jobCtx, cancel := context.WithCancel(parent)
|
||
s.runningMu.Lock()
|
||
s.running[job.ID] = cancel
|
||
s.runningMu.Unlock()
|
||
defer func() {
|
||
s.runningMu.Lock()
|
||
delete(s.running, job.ID)
|
||
s.runningMu.Unlock()
|
||
cancel()
|
||
}()
|
||
|
||
startedAt := time.Now()
|
||
tmpDir := filepath.Join(*workdir, job.ID)
|
||
_ = os.MkdirAll(tmpDir, 0o755)
|
||
defer os.RemoveAll(tmpDir)
|
||
|
||
if len(job.Profiles) == 0 {
|
||
s.finish(job, "failed", "no profiles", 0, 0)
|
||
return
|
||
}
|
||
profile := job.Profiles[0]
|
||
playlist := filepath.Join(tmpDir, "playlist.m3u8")
|
||
segPattern := filepath.Join(tmpDir, "seg-%d.ts")
|
||
args := buildFFmpegArgs(job, profile, segPattern, playlist)
|
||
|
||
cmd := exec.CommandContext(jobCtx, *ffmpeg, args...)
|
||
stderr := &bytes.Buffer{}
|
||
cmd.Stderr = stderr
|
||
fmt.Printf("[worker] running job %s, ffmpeg %s\n", job.ID, strings.Join(args, " "))
|
||
if err := cmd.Start(); err != nil {
|
||
s.finish(job, "failed", err.Error(), 0, 0)
|
||
return
|
||
}
|
||
|
||
// 上传协程
|
||
stopUpload := make(chan struct{})
|
||
uploadDone := make(chan struct{})
|
||
go func() {
|
||
defer close(uploadDone)
|
||
s.watchAndUpload(jobCtx, job, profile.Name, tmpDir, stopUpload)
|
||
}()
|
||
|
||
err := cmd.Wait()
|
||
close(stopUpload)
|
||
<-uploadDone
|
||
// 最后一次扫描:把还未上传的尾片传上去
|
||
s.uploadOnce(job, profile.Name, tmpDir, true)
|
||
|
||
if err != nil {
|
||
if jobCtx.Err() == context.Canceled {
|
||
s.finish(job, "cancelled", "cancelled by master", 0, 0)
|
||
return
|
||
}
|
||
s.finish(job, "failed", fmt.Sprintf("%v: %s", err, truncate(stderr.String(), 1024)), 0, 0)
|
||
return
|
||
}
|
||
s.finish(job, "finished", "", time.Since(startedAt).Seconds(), 0)
|
||
}
|
||
|
||
func (s *workerState) finish(job Job, status, errStr string, elapsed float64, bytesOut int64) {
|
||
req := FinishReq{
|
||
JobID: job.ID, Status: status, Error: errStr,
|
||
}
|
||
req.Stats.Elapsed = elapsed
|
||
req.Stats.BytesOut = bytesOut
|
||
if err := postJSON("/api/transcode/worker/job/finish", req, nil, job.CallbackToken); err != nil {
|
||
fmt.Printf("[worker] finish report failed: %v\n", err)
|
||
}
|
||
}
|
||
|
||
func (s *workerState) watchAndUpload(_ context.Context, job Job, profile, dir string, stop chan struct{}) {
|
||
t := time.NewTicker(800 * time.Millisecond)
|
||
defer t.Stop()
|
||
uploaded := map[int]bool{}
|
||
flush := func(final bool) {
|
||
s.uploadDelta(job, profile, dir, uploaded, final)
|
||
}
|
||
for {
|
||
select {
|
||
case <-stop:
|
||
flush(false)
|
||
return
|
||
case <-t.C:
|
||
flush(false)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *workerState) uploadOnce(job Job, profile, dir string, final bool) {
|
||
uploaded := map[int]bool{}
|
||
s.uploadDelta(job, profile, dir, uploaded, final)
|
||
}
|
||
|
||
// uploadDelta 扫描目录,上传新切片(uploaded 跟踪已传过的 seq;最后一片到 final 时才上传)
|
||
func (s *workerState) uploadDelta(job Job, profile, dir string, uploaded map[int]bool, final bool) {
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
return
|
||
}
|
||
type segFile struct {
|
||
seq int
|
||
path string
|
||
}
|
||
var segs []segFile
|
||
for _, e := range entries {
|
||
if e.IsDir() {
|
||
continue
|
||
}
|
||
n := e.Name()
|
||
if !strings.HasPrefix(n, "seg-") || !strings.HasSuffix(n, ".ts") {
|
||
continue
|
||
}
|
||
num, err := strconv.Atoi(strings.TrimSuffix(strings.TrimPrefix(n, "seg-"), ".ts"))
|
||
if err != nil {
|
||
continue
|
||
}
|
||
segs = append(segs, segFile{num, filepath.Join(dir, n)})
|
||
}
|
||
// 排序
|
||
for i := 0; i < len(segs); i++ {
|
||
for j := i + 1; j < len(segs); j++ {
|
||
if segs[j].seq < segs[i].seq {
|
||
segs[i], segs[j] = segs[j], segs[i]
|
||
}
|
||
}
|
||
}
|
||
limit := len(segs)
|
||
if !final && limit > 0 {
|
||
limit-- // 最后一片可能还在写,留到下一轮
|
||
}
|
||
segDur := job.Output.SegmentDuration
|
||
if segDur == 0 {
|
||
segDur = 6
|
||
}
|
||
for i := 0; i < limit; i++ {
|
||
seg := segs[i]
|
||
if uploaded[seg.seq] {
|
||
continue
|
||
}
|
||
isLast := final && i == limit-1
|
||
if err := s.uploadSegment(job, profile, seg.seq, float64(segDur), seg.path, isLast); err != nil {
|
||
fmt.Printf("[worker] upload seg-%d failed: %v\n", seg.seq, err)
|
||
return
|
||
}
|
||
uploaded[seg.seq] = true
|
||
}
|
||
}
|
||
|
||
func (s *workerState) uploadSegment(job Job, profile string, seq int, dur float64, file string, final bool) error {
|
||
f, err := os.Open(file)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer f.Close()
|
||
q := fmt.Sprintf("?job=%s&profile=%s&seq=%d&duration=%f&final=%t",
|
||
job.ID, profile, seq, dur, final)
|
||
req, _ := http.NewRequest(http.MethodPut, *masterURL+"/api/transcode/worker/segment"+q, f)
|
||
req.Header.Set("Content-Type", "video/mp2t")
|
||
req.Header.Set("Authorization", "Bearer "+job.CallbackToken)
|
||
resp, err := httpClient.Do(req)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
raw, _ := io.ReadAll(resp.Body)
|
||
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(raw))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// ---- ffmpeg args(与 master 端 local_runner 保持一致)----
|
||
|
||
// protocolWhitelistFor 与 internal/transcode.ProtocolWhitelistFor 保持一致:
|
||
// 远程输入只放行 HTTP(S) 及其传输层,本地路径只放行 file。
|
||
func protocolWhitelistFor(source string) string {
|
||
s := strings.ToLower(strings.TrimSpace(source))
|
||
if strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") {
|
||
// 与 master 的 protocolWhitelistRemote 保持一致:HTTP 源用不到 file/crypto,
|
||
// 留着等于给被构造过的播放列表留一条读本地文件的通道。
|
||
return "http,https,tcp,tls"
|
||
}
|
||
return "file"
|
||
}
|
||
|
||
func buildFFmpegArgs(job Job, profile Profile, segPattern, playlist 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")
|
||
}
|
||
// 限制 ffmpeg 可用协议:Master 下发的 source_url 不应被解释成 concat:/subfile: 等特殊协议。
|
||
// -protocol_whitelist 是输入选项,必须位于 -i 之前。
|
||
args = append(args, "-protocol_whitelist", protocolWhitelistFor(job.SourceURL))
|
||
args = append(args, "-i", job.SourceURL)
|
||
args = append(args, "-c:v", pickEncoder(profile.VideoCodec, hw))
|
||
if profile.VideoBitrate != "" {
|
||
args = append(args, "-b:v", profile.VideoBitrate, "-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)
|
||
}
|
||
}
|
||
if profile.AudioCodec == "copy" {
|
||
args = append(args, "-c:a", "copy")
|
||
} else {
|
||
ac := profile.AudioCodec
|
||
if ac == "" {
|
||
ac = "aac"
|
||
}
|
||
args = append(args, "-c:a", ac)
|
||
if profile.AudioBitrate != "" {
|
||
args = append(args, "-b:a", profile.AudioBitrate)
|
||
}
|
||
}
|
||
segDur := job.Output.SegmentDuration
|
||
if segDur == 0 {
|
||
segDur = 6
|
||
}
|
||
args = append(args,
|
||
"-f", "hls",
|
||
"-hls_time", strconv.Itoa(segDur),
|
||
"-hls_playlist_type", "vod",
|
||
"-hls_segment_filename", segPattern,
|
||
"-hls_flags", "independent_segments",
|
||
playlist,
|
||
)
|
||
return args
|
||
}
|
||
|
||
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"
|
||
}
|
||
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"
|
||
}
|
||
return "libx265"
|
||
case "av1":
|
||
switch hw {
|
||
case "nvenc":
|
||
return "av1_nvenc"
|
||
case "qsv":
|
||
return "av1_qsv"
|
||
}
|
||
return "libsvtav1"
|
||
}
|
||
return "libx264"
|
||
}
|
||
|
||
func truncate(s string, n int) string {
|
||
if len(s) <= n {
|
||
return s
|
||
}
|
||
return s[:n]
|
||
}
|