mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 11:36:36 +08:00
优化起播
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -24,10 +27,98 @@ func embyPlaybackInfoHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
// 在后台把本次条目的云盘直链换好:播放器拿到 PlaybackInfo 后通常还要
|
||||
// 1–2 秒才请求 /Videos/{id}/stream,把换链开销落在这段等待里。
|
||||
embyPrewarmPlaybackTargets(svc, c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// embyPrewarmTimeout 是单次预热的等待上限。115 开放平台在跨太平洋线路上单次
|
||||
// 换链实测 0.4–1.1s,这里给足余量;超时只是没预热成功,不影响后续播放。
|
||||
const embyPrewarmTimeout = 10 * time.Second
|
||||
|
||||
// embyPrewarmInFlight 去重同一个条目的并发预热(首页刷新会并发请求多个接口,
|
||||
// 同一条目可能在短时间内被多次请求)。
|
||||
var embyPrewarmInFlight sync.Map
|
||||
|
||||
// embyPrewarmSlots 限制同时进行的预热数量。客户端可能批量预取 PlaybackInfo
|
||||
// (逐个剧集的预取请求),预热只是优化,不能反过来把 115 换链接口打出突发。
|
||||
// 名额满时直接跳过:排队等待的预热往往等真正播放时已经没意义了。
|
||||
var embyPrewarmSlots = make(chan struct{}, 4)
|
||||
|
||||
// embyPrewarmPlaybackTargets 在后台预热本次 PlaybackInfo 涉及条目的云盘直链。
|
||||
//
|
||||
// 起播链路里最贵的一步是「服务端拿 pickcode 去 115 开放平台换直链」:服务器在
|
||||
// 洛杉矶、115 接口在国内,冷启动实测 0.4–1.1s;之后 45 分钟内命中进程内缓存。
|
||||
// 播放器在 PlaybackInfo 与真正拉流之间有几秒间隔,这里把换链放到那段间隔里,
|
||||
// 起播时就只剩纯网络耗时。
|
||||
//
|
||||
// 只处理云盘/strm 条目,且失败一律静默忽略:预热是尽力而为的优化,不能影响
|
||||
// PlaybackInfo 的正常返回。
|
||||
func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[string]any) {
|
||||
if svc == nil || svc.Strm == nil || svc.Repo == nil || svc.Repo.Media == nil || out == nil {
|
||||
return
|
||||
}
|
||||
ids := embyPrewarmMediaIDs(out)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
// 预热是给「后续请求」用的:即便本次 PlaybackInfo 的连接断开,
|
||||
// 也要把换链跑完。
|
||||
base := context.WithoutCancel(c.Request.Context())
|
||||
for _, mediaID := range ids {
|
||||
if _, loaded := embyPrewarmInFlight.LoadOrStore(mediaID, struct{}{}); loaded {
|
||||
continue
|
||||
}
|
||||
go func(id string) {
|
||||
defer embyPrewarmInFlight.Delete(id)
|
||||
select {
|
||||
case embyPrewarmSlots <- struct{}{}:
|
||||
defer func() { <-embyPrewarmSlots }()
|
||||
default:
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(base, embyPrewarmTimeout)
|
||||
defer cancel()
|
||||
m, err := svc.Repo.Media.FindByID(ctx, id)
|
||||
if err != nil || m == nil {
|
||||
return
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" || !service.IsStrmMediaRow(m) {
|
||||
return
|
||||
}
|
||||
// 解析结果由 strm 层按 pickcode+UA 缓存;已缓存时这里是空转。
|
||||
_, _ = svc.Strm.ResolvePlayTargetWithUA(ctx, raw, userAgent)
|
||||
}(mediaID)
|
||||
}
|
||||
}
|
||||
|
||||
// embyPrewarmMediaIDs 取出 PlaybackInfo 载荷里 MediaSources 的条目 ID。
|
||||
func embyPrewarmMediaIDs(out map[string]any) []string {
|
||||
sources, ok := out["MediaSources"].([]map[string]any)
|
||||
if !ok || len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
ids := make([]string, 0, len(sources))
|
||||
seen := make(map[string]struct{}, len(sources))
|
||||
for _, src := range sources {
|
||||
id, _ := src["Id"].(string)
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[id]; dup {
|
||||
continue
|
||||
}
|
||||
seen[id] = struct{}{}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// embySubtitleStreamHandler serves an external subtitle track advertised in a
|
||||
// MediaSource's MediaStreams via its Emby index
|
||||
// (/Videos/:id/Subtitles/:index/Stream). The index maps to a discovered
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
func newPrewarmTestContainer(t *testing.T) *service.Container {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open db: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Media{}, &model.Setting{}, &model.StrmAccount{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
if sqlDB, err := db.DB(); err == nil {
|
||||
// 内存库 + 后台预热协程:限制单连接,避免新连接拿到空白的 :memory:。
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
return &service.Container{
|
||||
Log: zap.NewNop(),
|
||||
Repo: repos,
|
||||
Strm: service.NewStrmService(&config.Config{}, zap.NewNop(), repos, nil),
|
||||
}
|
||||
}
|
||||
|
||||
func newPrewarmTestContext() *gin.Context {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/emby/Items/media-1/PlaybackInfo", nil)
|
||||
c.Request.Header.Set("User-Agent", "RodelPlayer/2.2607.7.0")
|
||||
return c
|
||||
}
|
||||
|
||||
func TestEmbyPrewarmMediaIDsExtractsDeduplicates(t *testing.T) {
|
||||
out := map[string]any{
|
||||
"MediaSources": []map[string]any{
|
||||
{"Id": "src-1"},
|
||||
{"Id": " src-1 "},
|
||||
{"Id": "src-2"},
|
||||
{"Id": ""},
|
||||
{"Name": "no id"},
|
||||
},
|
||||
}
|
||||
got := embyPrewarmMediaIDs(out)
|
||||
if len(got) != 2 || got[0] != "src-1" || got[1] != "src-2" {
|
||||
t.Fatalf("ids = %v, want [src-1 src-2]", got)
|
||||
}
|
||||
if got := embyPrewarmMediaIDs(map[string]any{}); len(got) != 0 {
|
||||
t.Fatalf("missing MediaSources should yield no ids, got %v", got)
|
||||
}
|
||||
if got := embyPrewarmMediaIDs(map[string]any{"MediaSources": []any{}}); len(got) != 0 {
|
||||
t.Fatalf("foreign payload shape should yield no ids, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 预热是异步的:调用必须立即返回,并且协程结束后不能残留去重标记。
|
||||
func TestEmbyPrewarmPlaybackTargetsRunsAsyncAndCleansUp(t *testing.T) {
|
||||
svc := newPrewarmTestContainer(t)
|
||||
if err := svc.Repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "media-1"},
|
||||
Title: "Cloud",
|
||||
Path: "cloud://cloud115/Movie.mkv",
|
||||
Container: "strm",
|
||||
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=missing&pickcode=pc1",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
embyPrewarmInFlight.Delete("media-1")
|
||||
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(), out)
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("embyPrewarmPlaybackTargets blocked the caller")
|
||||
}
|
||||
|
||||
// 后台协程应很快跑完并释放去重标记,否则同一条目后续再也预热不了。
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, busy := embyPrewarmInFlight.Load("media-1"); !busy {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("prewarm in-flight marker leaked")
|
||||
}
|
||||
|
||||
// 各种缺数据的情况都不允许 panic 或阻塞:预热只是尽力而为的优化。
|
||||
func TestEmbyPrewarmPlaybackTargetsIsNilSafe(t *testing.T) {
|
||||
svc := newPrewarmTestContainer(t)
|
||||
c := newPrewarmTestContext()
|
||||
out := map[string]any{"MediaSources": []map[string]any{{"Id": "media-1"}}}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
svc *service.Container
|
||||
out map[string]any
|
||||
}{
|
||||
{name: "空容器", svc: &service.Container{}, out: out},
|
||||
{name: "无 Strm", svc: &service.Container{Repo: svc.Repo}, out: out},
|
||||
{name: "无 Repo", svc: &service.Container{Strm: svc.Strm}, out: out},
|
||||
{name: "nil 载荷", svc: svc, out: nil},
|
||||
{name: "无 MediaSources", svc: svc, out: map[string]any{}},
|
||||
{name: "条目不存在", svc: svc, out: out},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
embyPrewarmPlaybackTargets(tc.svc, c, tc.out)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 本地文件条目不该触发换链预热(没有云盘直链可预热)。
|
||||
func TestEmbyPrewarmSkipsLocalMedia(t *testing.T) {
|
||||
svc := newPrewarmTestContainer(t)
|
||||
if err := svc.Repo.DB.Create(&model.Media{
|
||||
Base: model.Base{ID: "local-1"},
|
||||
Title: "Local",
|
||||
Path: "/media/movies/Local.mkv",
|
||||
LibraryID: "lib-1",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
embyPrewarmInFlight.Delete("local-1")
|
||||
embyPrewarmPlaybackTargets(svc, newPrewarmTestContext(),
|
||||
map[string]any{"MediaSources": []map[string]any{{"Id": "local-1"}}})
|
||||
|
||||
// 协程要么已经跑完(标记被清掉),要么根本没起;两种都不该留下标记。
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if _, busy := embyPrewarmInFlight.Load("local-1"); !busy {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("local media must not leave a prewarm marker")
|
||||
}
|
||||
Reference in New Issue
Block a user