mirror of
https://github.com/OpenListTeam/OpenList.git
synced 2026-11-10 20:50:50 +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>
490 lines
13 KiB
Go
490 lines
13 KiB
Go
package transcode
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// SegmentInfo 单个切片元数据
|
||
type SegmentInfo struct {
|
||
Seq int `json:"seq"`
|
||
Duration float64 `json:"duration"`
|
||
Size int64 `json:"size"`
|
||
Path string `json:"-"` // 本地实际文件路径
|
||
}
|
||
|
||
// JobCache 单个 Job 的切片缓存
|
||
type JobCache struct {
|
||
JobID string
|
||
Profile string
|
||
BaseDir string
|
||
|
||
mu sync.RWMutex
|
||
segments map[int]*SegmentInfo
|
||
final bool // 是否所有切片都到齐
|
||
waiters map[int][]chan struct{} // seq -> 等待这一片的 channel 列表
|
||
}
|
||
|
||
// Cache 全局切片缓存
|
||
type Cache struct {
|
||
rootDir string
|
||
maxBytes int64
|
||
mu sync.RWMutex
|
||
jobs map[string]map[string]*JobCache // jobID -> profile -> cache
|
||
}
|
||
|
||
func NewCache(rootDir string, maxGB int64) *Cache {
|
||
if maxGB <= 0 {
|
||
maxGB = 20
|
||
}
|
||
return &Cache{
|
||
rootDir: rootDir,
|
||
maxBytes: maxGB * 1024 * 1024 * 1024,
|
||
jobs: make(map[string]map[string]*JobCache),
|
||
}
|
||
}
|
||
|
||
// Init 创建根目录
|
||
func (c *Cache) Init() error {
|
||
return os.MkdirAll(c.rootDir, 0o755)
|
||
}
|
||
|
||
const (
|
||
// MaxSegmentBytes 单个切片允许写入的最大字节数,防止 callback_token 持有者打满磁盘
|
||
MaxSegmentBytes int64 = 32 << 20
|
||
// maxSegmentSeq 切片序号上限(按 2 秒/片约 6 天),拒绝负数与异常大值
|
||
maxSegmentSeq = 200000
|
||
)
|
||
|
||
var (
|
||
profileNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,32}$`)
|
||
jobIDRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
|
||
|
||
// ErrInvalidProfile profile 名称不合法(可能是路径穿越尝试)
|
||
ErrInvalidProfile = errors.New("invalid profile name")
|
||
// ErrInvalidJobID job id 不合法
|
||
ErrInvalidJobID = errors.New("invalid job id")
|
||
// ErrInvalidSegment 切片序号越界
|
||
ErrInvalidSegment = errors.New("invalid segment seq")
|
||
// ErrSegmentTooLarge 切片超过大小上限
|
||
ErrSegmentTooLarge = errors.New("segment too large")
|
||
)
|
||
|
||
// ValidProfileName 判断 profile 是否为安全的目录名(与 BuildProfile 产出的 source/1080p/720p 对齐)。
|
||
func ValidProfileName(profile string) bool {
|
||
return profileNameRe.MatchString(profile)
|
||
}
|
||
|
||
// safeJoin 在 rootDir 下拼接子路径,并保证结果不会越出 rootDir。
|
||
func (c *Cache) safeJoin(jobID, profile string) (string, error) {
|
||
if !jobIDRe.MatchString(jobID) {
|
||
return "", ErrInvalidJobID
|
||
}
|
||
if !ValidProfileName(profile) {
|
||
return "", ErrInvalidProfile
|
||
}
|
||
full := filepath.Join(c.rootDir, jobID, profile)
|
||
rel, err := filepath.Rel(c.rootDir, full)
|
||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||
return "", ErrInvalidProfile
|
||
}
|
||
return full, nil
|
||
}
|
||
|
||
// getOrCreate 获取或创建 (job, profile) 的 cache
|
||
func (c *Cache) getOrCreate(jobID, profile string) (*JobCache, error) {
|
||
baseDir, err := c.safeJoin(jobID, profile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
c.mu.Lock()
|
||
defer c.mu.Unlock()
|
||
pmap, ok := c.jobs[jobID]
|
||
if !ok {
|
||
pmap = make(map[string]*JobCache)
|
||
c.jobs[jobID] = pmap
|
||
}
|
||
jc, ok := pmap[profile]
|
||
if !ok {
|
||
jc = &JobCache{
|
||
JobID: jobID,
|
||
Profile: profile,
|
||
BaseDir: baseDir,
|
||
segments: make(map[int]*SegmentInfo),
|
||
waiters: make(map[int][]chan struct{}),
|
||
}
|
||
if err := os.MkdirAll(jc.BaseDir, 0o755); err != nil {
|
||
return nil, err
|
||
}
|
||
pmap[profile] = jc
|
||
}
|
||
return jc, nil
|
||
}
|
||
|
||
// PutSegment 写入一个切片,并通知所有等待者
|
||
func (c *Cache) PutSegment(jobID, profile string, seq int, duration float64, body io.Reader, isFinal bool) (*SegmentInfo, error) {
|
||
if seq < 0 || seq > maxSegmentSeq {
|
||
return nil, ErrInvalidSegment
|
||
}
|
||
jc, err := c.getOrCreate(jobID, profile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fname := fmt.Sprintf("seg-%d.ts", seq)
|
||
fpath := filepath.Join(jc.BaseDir, fname)
|
||
tmpPath := fpath + ".tmp"
|
||
f, err := os.Create(tmpPath)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// 多读 1 字节用于判断是否超限
|
||
n, err := io.Copy(f, io.LimitReader(body, MaxSegmentBytes+1))
|
||
if err == nil && n > MaxSegmentBytes {
|
||
err = ErrSegmentTooLarge
|
||
}
|
||
cerr := f.Close()
|
||
if err != nil {
|
||
os.Remove(tmpPath)
|
||
return nil, err
|
||
}
|
||
if cerr != nil {
|
||
os.Remove(tmpPath)
|
||
return nil, cerr
|
||
}
|
||
if err := os.Rename(tmpPath, fpath); err != nil {
|
||
os.Remove(tmpPath)
|
||
return nil, err
|
||
}
|
||
info := &SegmentInfo{Seq: seq, Duration: duration, Size: n, Path: fpath}
|
||
|
||
jc.mu.Lock()
|
||
jc.segments[seq] = info
|
||
if isFinal {
|
||
jc.final = true
|
||
}
|
||
// 通知所有等待 <= seq 的 waiter
|
||
var toClose []chan struct{}
|
||
for s, chs := range jc.waiters {
|
||
if s <= seq {
|
||
toClose = append(toClose, chs...)
|
||
delete(jc.waiters, s)
|
||
}
|
||
}
|
||
jc.mu.Unlock()
|
||
for _, ch := range toClose {
|
||
close(ch)
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
// GetSegment 读取一个切片(不等待)
|
||
func (c *Cache) GetSegment(jobID, profile string, seq int) (*SegmentInfo, error) {
|
||
c.mu.RLock()
|
||
pmap, ok := c.jobs[jobID]
|
||
c.mu.RUnlock()
|
||
if !ok {
|
||
return nil, errors.New("job not found")
|
||
}
|
||
jc, ok := pmap[profile]
|
||
if !ok {
|
||
return nil, errors.New("profile not found")
|
||
}
|
||
jc.mu.RLock()
|
||
defer jc.mu.RUnlock()
|
||
info, ok := jc.segments[seq]
|
||
if !ok {
|
||
return nil, errors.New("segment not found")
|
||
}
|
||
return info, nil
|
||
}
|
||
|
||
// WaitForSegment 等待某个 seq 切片就绪,timeout 后返回 nil
|
||
func (c *Cache) WaitForSegment(jobID, profile string, seq int, timeout time.Duration) (*SegmentInfo, error) {
|
||
jc, err := c.getOrCreate(jobID, profile)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
jc.mu.Lock()
|
||
if info, ok := jc.segments[seq]; ok {
|
||
jc.mu.Unlock()
|
||
return info, nil
|
||
}
|
||
if jc.final {
|
||
jc.mu.Unlock()
|
||
return nil, errors.New("segment never produced")
|
||
}
|
||
ch := make(chan struct{})
|
||
jc.waiters[seq] = append(jc.waiters[seq], ch)
|
||
jc.mu.Unlock()
|
||
select {
|
||
case <-ch:
|
||
jc.mu.RLock()
|
||
info := jc.segments[seq]
|
||
jc.mu.RUnlock()
|
||
if info == nil {
|
||
return nil, errors.New("segment not produced")
|
||
}
|
||
return info, nil
|
||
case <-time.After(timeout):
|
||
// 【内存泄漏修复】超时后必须从 waiters map 中移除自己的 channel,
|
||
// 否则 channel 会永远留在 map 中无法被 GC
|
||
jc.mu.Lock()
|
||
if chs, ok := jc.waiters[seq]; ok {
|
||
for i, w := range chs {
|
||
if w == ch {
|
||
jc.waiters[seq] = append(chs[:i], chs[i+1:]...)
|
||
break
|
||
}
|
||
}
|
||
if len(jc.waiters[seq]) == 0 {
|
||
delete(jc.waiters, seq)
|
||
}
|
||
}
|
||
jc.mu.Unlock()
|
||
return nil, errors.New("timeout waiting segment")
|
||
}
|
||
}
|
||
|
||
// SegmentCount 返回 (jobID, profile) 当前已生成的切片数量。
|
||
// 用于 TCPlaylist 在首次返回前等待至少若干切片就绪,避免 playlist
|
||
// 中只有 seg-0 导致 HLS.js 反复请求填充缓冲。
|
||
func (c *Cache) SegmentCount(jobID, profile string) int {
|
||
c.mu.RLock()
|
||
pmap, ok := c.jobs[jobID]
|
||
c.mu.RUnlock()
|
||
if !ok {
|
||
return 0
|
||
}
|
||
jc, ok := pmap[profile]
|
||
if !ok {
|
||
return 0
|
||
}
|
||
jc.mu.RLock()
|
||
defer jc.mu.RUnlock()
|
||
return len(jc.segments)
|
||
}
|
||
|
||
// BuildPlaylist 生成 HLS playlist (m3u8)
|
||
// 【重要】当 totalDuration > 0 时,会根据总时长预先生成完整切片列表(VOD 模式),
|
||
// 即使切片还未生成,URL 也会出现在 playlist 中。HLS.js 请求未生成切片时
|
||
// 会阻塞等待(见 TCSegment 的 WaitForSegment)。这样可以让 HLS.js 知道
|
||
// 视频总时长,避免:
|
||
// 1. 时长显示错误(只显示已转码部分)
|
||
// 2. 拖动跳跃(playlist 增长导致切片序号重新对齐)
|
||
func (c *Cache) BuildPlaylist(jobID, profile string, segDur int, completed bool, baseURL string, totalDuration float64) string {
|
||
c.mu.RLock()
|
||
pmap, ok := c.jobs[jobID]
|
||
c.mu.RUnlock()
|
||
if !ok {
|
||
return ""
|
||
}
|
||
jc, ok := pmap[profile]
|
||
if !ok {
|
||
return ""
|
||
}
|
||
jc.mu.RLock()
|
||
defer jc.mu.RUnlock()
|
||
|
||
var b strings.Builder
|
||
b.WriteString("#EXTM3U\n")
|
||
b.WriteString("#EXT-X-VERSION:3\n")
|
||
b.WriteString("#EXT-X-TARGETDURATION:" + strconv.Itoa(segDur) + "\n")
|
||
b.WriteString("#EXT-X-MEDIA-SEQUENCE:0\n")
|
||
|
||
// 如果有总时长,按总时长生成完整切片列表
|
||
if totalDuration > 0 {
|
||
// VOD 模式:完整列表,HLS.js 知道总时长
|
||
b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
|
||
fSegDur := float64(segDur)
|
||
// 总切片数 = ceil(总时长 / 每片时长)
|
||
totalSegs := int(totalDuration / fSegDur)
|
||
lastDur := totalDuration - float64(totalSegs)*fSegDur
|
||
if lastDur > 0.01 {
|
||
totalSegs++
|
||
}
|
||
for i := 0; i < totalSegs; i++ {
|
||
dur := fSegDur
|
||
if i == totalSegs-1 && lastDur > 0.01 {
|
||
dur = lastDur
|
||
}
|
||
// 优先使用真实时长(如果已知)
|
||
if seg, ok := jc.segments[i]; ok && seg.Duration > 0 {
|
||
dur = seg.Duration
|
||
}
|
||
b.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", dur))
|
||
b.WriteString(fmt.Sprintf("%sseg-%d.ts\n", baseURL, i))
|
||
}
|
||
b.WriteString("#EXT-X-ENDLIST\n")
|
||
return b.String()
|
||
}
|
||
|
||
// fallback: 没有总时长时退回到原 EVENT 模式(保留旧行为)
|
||
seqs := make([]int, 0, len(jc.segments))
|
||
for s := range jc.segments {
|
||
seqs = append(seqs, s)
|
||
}
|
||
sort.Ints(seqs)
|
||
if jc.final || completed {
|
||
b.WriteString("#EXT-X-PLAYLIST-TYPE:VOD\n")
|
||
} else {
|
||
b.WriteString("#EXT-X-PLAYLIST-TYPE:EVENT\n")
|
||
}
|
||
for _, s := range seqs {
|
||
seg := jc.segments[s]
|
||
b.WriteString(fmt.Sprintf("#EXTINF:%.3f,\n", seg.Duration))
|
||
b.WriteString(fmt.Sprintf("%sseg-%d.ts\n", baseURL, s))
|
||
}
|
||
if jc.final || completed {
|
||
b.WriteString("#EXT-X-ENDLIST\n")
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// MasterPlaylist 多档位主 m3u8
|
||
func (c *Cache) MasterPlaylist(profiles []Profile, baseURL string) string {
|
||
var b strings.Builder
|
||
b.WriteString("#EXTM3U\n#EXT-X-VERSION:3\n")
|
||
for _, p := range profiles {
|
||
bw, _ := parseBitrate(p.VideoBitrate)
|
||
ab, _ := parseBitrate(p.AudioBitrate)
|
||
// 【关键修复】根据实际编码动态生成 codec 字符串
|
||
// 错误的 codec 声明会导致 HLS.js 用错误参数初始化 SourceBuffer
|
||
// 然后 appendBuffer 时数据格式不匹配 → bufferAppendError 死循环
|
||
codecs := buildCodecString(p)
|
||
b.WriteString(fmt.Sprintf("#EXT-X-STREAM-INF:BANDWIDTH=%d,CODECS=\"%s\"\n", bw+ab, codecs))
|
||
b.WriteString(fmt.Sprintf("%s%s/playlist.m3u8\n", baseURL, p.Name))
|
||
}
|
||
return b.String()
|
||
}
|
||
|
||
// buildCodecString 根据 Profile 生成 HLS CODECS 属性字符串
|
||
// avc1.42E01E = H.264 Baseline Profile Level 3.0(兼容性最好)
|
||
// avc1.4D401E = H.264 Main Profile Level 3.0
|
||
// avc1.640028 = H.264 High Profile Level 4.0
|
||
// mp4a.40.2 = AAC-LC(HE-AAC v1 用 mp4a.40.5,HE-AAC v2 用 mp4a.40.29)
|
||
func buildCodecString(p Profile) string {
|
||
// 视频部分:默认 H.264 Main Profile Level 4.0,平衡兼容性与质量
|
||
video := "avc1.4D4028"
|
||
switch strings.ToLower(p.VideoCodec) {
|
||
case "hevc", "h265":
|
||
// HEVC Main Profile Level 4.1,部分 Safari/Edge 支持
|
||
video = "hvc1.1.6.L120.90"
|
||
case "av1":
|
||
video = "av01.0.05M.08"
|
||
}
|
||
// 音频部分:固定 AAC-LC(后端已强制转 AAC)
|
||
audio := "mp4a.40.2"
|
||
return video + "," + audio
|
||
}
|
||
|
||
func parseBitrate(s string) (int64, error) {
|
||
s = strings.TrimSpace(strings.ToLower(s))
|
||
if s == "" {
|
||
return 0, nil
|
||
}
|
||
mult := int64(1)
|
||
if strings.HasSuffix(s, "k") {
|
||
mult = 1000
|
||
s = strings.TrimSuffix(s, "k")
|
||
} else if strings.HasSuffix(s, "m") {
|
||
mult = 1_000_000
|
||
s = strings.TrimSuffix(s, "m")
|
||
}
|
||
v, err := strconv.ParseInt(s, 10, 64)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
return v * mult, nil
|
||
}
|
||
|
||
// Cleanup 删除某个 job 的所有切片,并关闭所有阻塞的 waiter goroutine
|
||
func (c *Cache) Cleanup(jobID string) {
|
||
c.mu.Lock()
|
||
pmap, exists := c.jobs[jobID]
|
||
delete(c.jobs, jobID)
|
||
c.mu.Unlock()
|
||
// 【内存泄漏修复】关闭所有 profile 下阻塞等待的 waiter channel,
|
||
// 避免 goroutine 永远挂起(每个 goroutine 至少 8KB 栈空间)
|
||
if exists {
|
||
for _, jc := range pmap {
|
||
jc.mu.Lock()
|
||
jc.final = true // 标记为 final,防止新 waiter 进入
|
||
for seq, chs := range jc.waiters {
|
||
for _, ch := range chs {
|
||
close(ch)
|
||
}
|
||
delete(jc.waiters, seq)
|
||
}
|
||
jc.mu.Unlock()
|
||
}
|
||
}
|
||
_ = os.RemoveAll(filepath.Join(c.rootDir, jobID))
|
||
}
|
||
|
||
// EnforceLRU 简易 LRU 容量控制:超过 maxBytes 时按目录修改时间从早到晚删除
|
||
func (c *Cache) EnforceLRU() error {
|
||
entries, err := os.ReadDir(c.rootDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
type item struct {
|
||
dir string
|
||
size int64
|
||
mtime time.Time
|
||
}
|
||
var items []item
|
||
var total int64
|
||
for _, e := range entries {
|
||
// 只管理本模块创建的 job 目录,避免误删缓存根目录下的其它内容
|
||
if !e.IsDir() || !strings.HasPrefix(e.Name(), "job_") {
|
||
continue
|
||
}
|
||
full := filepath.Join(c.rootDir, e.Name())
|
||
size := dirSize(full)
|
||
fi, _ := os.Stat(full)
|
||
mt := time.Now()
|
||
if fi != nil {
|
||
mt = fi.ModTime()
|
||
}
|
||
items = append(items, item{full, size, mt})
|
||
total += size
|
||
}
|
||
if total <= c.maxBytes {
|
||
return nil
|
||
}
|
||
sort.Slice(items, func(i, j int) bool { return items[i].mtime.Before(items[j].mtime) })
|
||
for _, it := range items {
|
||
if total <= c.maxBytes {
|
||
break
|
||
}
|
||
_ = os.RemoveAll(it.dir)
|
||
total -= it.size
|
||
// 同步从内存里删
|
||
jobID := filepath.Base(it.dir)
|
||
c.mu.Lock()
|
||
delete(c.jobs, jobID)
|
||
c.mu.Unlock()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func dirSize(dir string) int64 {
|
||
var size int64
|
||
_ = filepath.Walk(dir, func(_ string, info os.FileInfo, err error) error {
|
||
if err == nil && info != nil && !info.IsDir() {
|
||
size += info.Size()
|
||
}
|
||
return nil
|
||
})
|
||
return size
|
||
}
|