Fix cloud playback and organize ingest pipeline

This commit is contained in:
ShukeBta
2026-06-13 23:21:56 +08:00
parent 4b2839d753
commit 52b772ea45
18 changed files with 729 additions and 22 deletions
+1
View File
@@ -330,6 +330,7 @@ func serveCloudResolvedLink(svc *service.Container, c *gin.Context, typ, ref str
}
if !link.Proxy {
// Pure offload: send the client straight to the cloud CDN.
setRedirectNoStoreHeaders(c)
logCloudPlayback(svc, "cloud playback redirect",
append(cloudPlaybackLogFields(typ, ref, link, resolveDur),
zap.String("mode", "redirect"),
+46
View File
@@ -1059,6 +1059,15 @@ func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.Handle
c.Status(http.StatusNotFound)
return
}
if embyShouldRedirectVideoStreamToSTRM(c, svc, c.Param("id"), cloudMode) {
target := "/api/stream/" + url.PathEscape(strings.TrimSpace(c.Param("id")))
if token := embyPlaybackRedirectToken(c, svc); token != "" {
target = embyAppendAPIKey(target, token)
}
setRedirectNoStoreHeaders(c)
c.Redirect(http.StatusFound, absoluteRequestURL(c, target))
return
}
// 直接调用 Stream service 写入 response。
// 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
@@ -1080,6 +1089,43 @@ func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.Handle
}
}
func embyPlaybackRedirectToken(c *gin.Context, svc *service.Container) string {
if token := embyRequestToken(c); token != "" {
return token
}
if c == nil || svc == nil || svc.Auth == nil || svc.Repo == nil || svc.Repo.User == nil {
return ""
}
uid := embyUserID(c)
if uid == "" {
return ""
}
u, err := svc.Repo.User.FindByID(c.Request.Context(), uid)
if err != nil || u == nil {
return ""
}
token, err := svc.Auth.IssueEmbyToken(u)
if err != nil {
return ""
}
return token
}
func embyShouldRedirectVideoStreamToSTRM(c *gin.Context, svc *service.Container, mediaID, cloudMode string) bool {
if c == nil || svc == nil || svc.Repo == nil || svc.Repo.Media == nil || cloudMode != service.CloudPlaybackModeRedirectProxy {
return false
}
settings := service.CloudPlaybackSettings(c.Request.Context(), svc.Repo)
if settings.PreferredMode != service.CloudPlaybackModeSTRM || !settings.STRMEnabled {
return false
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), mediaID)
if err != nil || m == nil {
return false
}
return strings.TrimSpace(m.STRMURL) != ""
}
func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
uid := embyUserID(c)
+146
View File
@@ -1063,6 +1063,152 @@ func TestEmbyItemsTokenizesEmbeddedCloudMediaSources(t *testing.T) {
}
}
func TestEmbyVideoStreamUsesSTRMWhenRedirectProxyDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
t.Fatalf("set cloud playback mode: %v", err)
}
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil {
t.Fatalf("enable strm playback: %v", err)
}
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil {
t.Fatalf("disable redirect playback: %v", err)
}
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
LibraryID: lib.ID,
Title: "Cloud Movie",
Path: "cloud://openlist/Movies/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
Container: "mkv",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
router := gin.New()
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}}
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil),
})
token := signedTestToken(t, secret)
req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream?api_key="+token, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") {
t.Fatalf("STRM mode should redirect /Videos fallback to tokenized /api/stream, got %q", loc)
}
if got := w.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
t.Fatalf("STRM fallback redirect Cache-Control = %q, want no-store", got)
}
if strings.Contains(loc, "/api/cloud/play/") {
t.Fatalf("STRM mode should not expose cloud play directly from /Videos fallback: %q", loc)
}
}
func TestEmbyVideoStreamIssuesTokenForSessionFallbackSTRMRedirect(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackModeSettingKey, service.CloudPlaybackModeSTRM); err != nil {
t.Fatalf("set cloud playback mode: %v", err)
}
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackSTRMEnabledSettingKey, "true"); err != nil {
t.Fatalf("enable strm playback: %v", err)
}
if err := repos.Setting.Set(t.Context(), service.CloudPlaybackRedirectEnabledSettingKey, "false"); err != nil {
t.Fatalf("disable redirect playback: %v", err)
}
user := model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}
if err := repos.User.Create(t.Context(), &user); err != nil {
t.Fatalf("create user: %v", err)
}
lib := model.Library{Name: "OpenList", Path: "cloud://openlist/Movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
LibraryID: lib.ID,
Title: "Cloud Movie",
Path: "cloud://openlist/Movies/Movie.mkv",
STRMURL: "/api/cloud/play/openlist?ref=%2FMovies%2FMovie.mkv",
Container: "mkv",
}).Error; err != nil {
t.Fatalf("create media: %v", err)
}
const secret = "test-secret"
cfg := &config.Config{Secrets: config.SecretsConfig{JWTSecret: secret}}
svc := &service.Container{
Repo: repos,
Auth: service.NewAuthService(cfg, zap.NewNop(), repos, nil, nil),
Emby: service.NewEmbyService(cfg, zap.NewNop(), repos),
Stream: service.NewStreamService(cfg, zap.NewNop(), repos, nil),
}
router := gin.New()
router.GET("/videos/:id/stream", func(c *gin.Context) {
c.Set(middleware.CtxUserID, user.ID)
c.Set(middleware.CtxUserRole, user.Role)
embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)(c)
})
req := httptest.NewRequest(http.MethodGet, "/videos/cloud-1/stream", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/api/stream/cloud-1") || !strings.Contains(loc, "api_key=") {
t.Fatalf("session fallback redirect should include api_key for /api/stream, got %q", loc)
}
}
func TestEmbyLowercaseVideoStreamRouteServesMedia(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
+9
View File
@@ -159,6 +159,15 @@ func absoluteRequestURL(c *gin.Context, path string) string {
return scheme + "://" + host + path
}
func setRedirectNoStoreHeaders(c *gin.Context) {
if c == nil {
return
}
c.Header("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0")
c.Header("Pragma", "no-cache")
c.Header("Expires", "0")
}
// transcodeStatusHandler reports the live status of one transcode job.
// We surface the active jobs the transcoder knows about.
func transcodeStatusHandler(svc *service.Container) gin.HandlerFunc {
+5 -1
View File
@@ -118,10 +118,14 @@ func generateSTRMHandler(svc *service.Container) gin.HandlerFunc {
if strmSvc == nil {
strmSvc = service.NewSTRMService(svc.Log, svc.Repo, svc.Cfg)
}
baseURL := strings.TrimRight(strings.TrimSpace(req.BaseURL), "/")
if baseURL == "" {
baseURL = strings.TrimRight(absoluteRequestURL(c, "/"), "/")
}
res, err := strmSvc.GenerateForLibrary(c.Request.Context(), service.GenerateSTRMOptions{
LibraryID: req.LibraryID,
OutputDir: req.OutputDir,
BaseURL: req.BaseURL,
BaseURL: baseURL,
Enabled: req.Enabled,
Overwrite: req.Overwrite,
IncludeLocal: true,