fix(emby): 电影条目不下发空的 SeasonId/SeriesId,修复小幻影视起播失败

小幻影视(RodelPlayer)2.2610 会把电影条目的 SeasonId 空串原样传进自己的
播放准备逻辑并抛 ArgumentException(Parameter 'seasonId'),客户端日志里
表现为 failedStage=PreparationFailed、playback generation null,也就是
点播放立刻「播放失败」;剧集条目的 SeasonId 非空,所以同一台设备放剧集正常。

Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键)而不是下发空串。
这里对非剧集条目省略 SeasonId/SeriesId/SeasonName/SeriesName(顺带去掉电影被
标成「特别篇」的 SeasonName),剧集则保留真实季/剧信息、只清空值。

同时回退上一版针对 115 直链 UA 绑定的同源一跳改动:客户端日志显示该客户端的
播放器层与 API 层 UA 是一致的(剧集走服务端换链后的一跳直连可以正常解码播放),
不需要为此牺牲起播速度。
This commit is contained in:
truewhile
2026-09-28 22:51:40 +08:00
parent 1d73374fff
commit 8136e6ecb1
6 changed files with 117 additions and 235 deletions
-25
View File
@@ -348,12 +348,6 @@ func embyVideoStreamHandler(svc *service.Container, cloudMode string) gin.Handle
// 此前这里把所有错误一律吞成 404:云盘 Cookie 过期、直链解析失败、
// STRM 播放被关闭……在第三方播放器上全部表现为「404 不存在」,
// 无法排查。现在区分:行不存在→404;云盘播放不可用/上游故障→502+原因。
//
// 网盘直链与换取时的 UA 严格绑定,而 Emby 客户端的 API 层与播放器层 UA
// 不保证相同(浏览器一定相同),所以只有浏览器客户端允许服务端短路换链。
if !isBrowserLikeUserAgent(c.GetHeader("User-Agent")) {
c.Request = c.Request.WithContext(service.WithClientSideSTRMResolve(c.Request.Context()))
}
err = svc.Stream.ServeFileWithCloudMode(c.Writer, c.Request, c.Param("id"), cloudMode)
switch {
case err == nil:
@@ -393,25 +387,6 @@ func embyPlaybackRedirectToken(c *gin.Context, svc *service.Container) string {
return token
}
// isBrowserLikeUserAgent 判断请求是否来自浏览器(含 WebView / Electron 客户端)。
//
// 只有浏览器能保证「API 请求」与「<video> 拉流」用同一个 User-Agent,因此也只有
// 浏览器客户端可以安全地接受服务端换链后的 CDN 直链(见
// service.WithClientSideSTRMResolve 说明)。原生播放器的播放器层与 API 层 UA
// 不保证一致,必须让客户端自己去同源 strm 端点换链。
func isBrowserLikeUserAgent(ua string) bool {
ua = strings.ToLower(strings.TrimSpace(ua))
if ua == "" {
return false
}
for _, marker := range []string{"mozilla/", "applewebkit/", "gecko/"} {
if strings.Contains(ua, marker) {
return true
}
}
return false
}
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
@@ -1,138 +0,0 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"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"
"github.com/truewhile/MeBox/internal/service/cloud"
)
// newEmbyStreamResolveTestContainer 造一个最小的 Emby 播放容器:一条云盘 strm 媒体
// 加一个必定被调用的换链解析器,用于观察服务端是否替客户端换链。
func newEmbyStreamResolveTestContainer(t *testing.T, resolver *countingStrmResolver) *service.Container {
t.Helper()
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.Media{}, &model.Setting{}, &model.StrmAccount{}, &model.Library{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "cloud-1"},
Title: "Cloud",
Path: "cloud://cloud115/Movie.mkv",
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}).Error; err != nil {
t.Fatalf("seed media: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
log := zap.NewNop()
return &service.Container{
Cfg: cfg,
Log: log,
Repo: repos,
Emby: service.NewEmbyService(cfg, log, repos),
Stream: service.NewStreamService(cfg, log, repos, nil).SetStrmPlayTargetResolver(resolver.Resolve),
}
}
type countingStrmResolver struct {
calls int
}
func (r *countingStrmResolver) Resolve(context.Context, string, string) (*service.StrmPlayResult, error) {
r.calls++
link := "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
return &service.StrmPlayResult{
RedirectURL: link,
Link: &cloud.DirectLink{URL: link, Headers: map[string]string{"User-Agent": "ua"}},
}, nil
}
func serveEmbyVideoStream(t *testing.T, svc *service.Container, userAgent string) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/emby/Videos/cloud-1/stream?api_key=jwt123", nil)
c.Request.Header.Set("User-Agent", userAgent)
c.Params = gin.Params{{Key: "id", Value: "cloud-1"}}
embyVideoStreamHandler(svc, service.CloudPlaybackModeRedirectProxy)(c)
return rec
}
// 115 直链与换取时的 User-Agent 严格绑定(换错一个字符 CDN 就 403),而原生
// 播放器(小幻影视 / RodelPlayer)的 API 层与播放器层 UA 并不一致,因此对非
// 浏览器客户端绝不能在服务端替它换链,必须让客户端自己去同源端点换链。
func TestEmbyVideoStreamDoesNotResolveDirectLinkForNativePlayer(t *testing.T) {
resolver := &countingStrmResolver{}
svc := newEmbyStreamResolveTestContainer(t, resolver)
w := serveEmbyVideoStream(t, svc, "RodelPlayer/2.2610.2.0 (Windows NT 10.0.26200; x64)")
if w.Code != http.StatusFound {
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
}
if resolver.calls != 0 {
t.Fatalf("server-side resolve called %d times, want 0", resolver.calls)
}
loc := w.Header().Get("Location")
if !strings.Contains(loc, "/api/strm/play/cloud115/video.mkv") {
t.Fatalf("Location = %q, want the same-origin strm endpoint", loc)
}
if strings.Contains(loc, "115cdn.net") {
t.Fatalf("Location = %q, must not point straight at the CDN for a native player", loc)
}
}
// 浏览器的 XHR 与 <video> 拉流必然是同一个 UA,可以继续享受服务端换链少一跳。
func TestEmbyVideoStreamResolvesDirectLinkForBrowser(t *testing.T) {
resolver := &countingStrmResolver{}
svc := newEmbyStreamResolveTestContainer(t, resolver)
w := serveEmbyVideoStream(t, svc, "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36")
if w.Code != http.StatusFound {
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
}
if resolver.calls != 1 {
t.Fatalf("server-side resolve called %d times, want 1", resolver.calls)
}
if loc := w.Header().Get("Location"); !strings.Contains(loc, "115cdn.net") {
t.Fatalf("Location = %q, want the resolved direct link", loc)
}
}
func TestIsBrowserLikeUserAgent(t *testing.T) {
cases := []struct {
ua string
want bool
}{
{"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36", true},
{"Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15", true},
{"RodelPlayer/2.2610.2.0 (Windows NT 10.0.26200; x64)", false},
{"Yamby/2.0.5.5(Android", false},
{"Infuse-Direct/8.0", false},
{"", false},
}
for _, tc := range cases {
if got := isBrowserLikeUserAgent(tc.ua); got != tc.want {
t.Fatalf("isBrowserLikeUserAgent(%q) = %v, want %v", tc.ua, got, tc.want)
}
}
}