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
@@ -0,0 +1,92 @@
package service
import (
"testing"
"github.com/truewhile/MeBox/internal/model"
)
// Emby/Jellyfin 对「不适用」的字段不下发(JSON 里没有该键),而不是下发空串。
// 小幻影视(RodelPlayer)2.2610 会把电影条目的 "SeasonId": "" 原样传进播放准备
// 逻辑并抛 ArgumentException(Parameter 'seasonId'),导致整个起播失败。
func TestEmbyMovieItemOmitsEmptySeasonAndSeriesFields(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "小姐姐", Path: `/media/小姐姐`, Type: "movie", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
movie := model.Media{
Base: model.Base{ID: "movie-strm"},
LibraryID: lib.ID,
Title: "SNIS-919",
Path: `/media/小姐姐/三上悠亚/SNIS-919/SNIS-919.strm`,
Container: "strm",
STRMURL: "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1",
}
if err := svc.repo.DB.Create(&movie).Error; err != nil {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &movie, false, 0, false)
if item["Type"] != "Movie" {
t.Fatalf("Type = %#v, want Movie", item["Type"])
}
for _, key := range []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"} {
if value, ok := item[key]; ok {
t.Fatalf("%s must be omitted for a movie, got %#v", key, value)
}
}
}
// 剧集的季/剧归属是真实存在的,不能被一起删掉。
func TestEmbyEpisodeItemKeepsSeasonAndSeriesFields(t *testing.T) {
svc := newTestEmbyService(t)
lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true}
if err := svc.repo.Library.Create(t.Context(), &lib); err != nil {
t.Fatalf("create library: %v", err)
}
episode := model.Media{
Base: model.Base{ID: "ep-1"},
LibraryID: lib.ID,
Title: "命运石之门",
Path: `/media/tv/命运石之门/Season 01/命运石之门 - S01E01.mkv`,
SeasonNum: 1,
EpisodeNum: 1,
}
if err := svc.repo.DB.Create(&episode).Error; err != nil {
t.Fatalf("create media: %v", err)
}
item := svc.itemPayload(t.Context(), &episode, false, 0, false)
if item["Type"] != "Episode" {
t.Fatalf("Type = %#v, want Episode", item["Type"])
}
for _, key := range []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"} {
value, _ := item[key].(string)
if value == "" {
t.Fatalf("%s = %#v, want a non-empty value for an episode", key, item[key])
}
}
}
func TestDropEmptyStringFields(t *testing.T) {
item := map[string]any{
"SeasonId": "",
"SeriesId": " ",
"SeriesName": "命运石之门",
"IndexNumber": 0,
}
dropEmptyStringFields(item, "SeasonId", "SeriesId", "SeriesName")
if _, ok := item["SeasonId"]; ok {
t.Fatalf("empty SeasonId must be removed")
}
if _, ok := item["SeriesId"]; ok {
t.Fatalf("whitespace-only SeriesId must be removed")
}
if item["SeriesName"] != "命运石之门" {
t.Fatalf("non-empty SeriesName must be kept, got %#v", item["SeriesName"])
}
if item["IndexNumber"] != 0 {
t.Fatalf("non-string fields must be left alone, got %#v", item["IndexNumber"])
}
}
+25
View File
@@ -787,9 +787,34 @@ func (e *EmbyService) itemPayload(ctx context.Context, m *model.Media, fav bool,
if includePeople {
item["People"] = e.resolveMediaPeople(ctx, m)
}
// Emby/Jellyfin 对「不适用」的字段是直接不下发(JSON 里没有该键),而不是下发
// 空串或占位值。电影等非剧集条目没有季/剧归属,小幻影视(RodelPlayer)2.2610
// 会把 "SeasonId": "" 原样传进播放准备逻辑并抛 ArgumentException
// (Parameter 'seasonId'),起播直接失败;"SeasonName": "特别篇" 也会让客户端
// 把电影当成特别篇展示。剧集则保留真实的季/剧信息,只清掉空值。
seasonKeys := []string{"SeasonId", "SeriesId", "SeasonName", "SeriesName"}
if itemType == "Episode" {
dropEmptyStringFields(item, seasonKeys...)
} else {
for _, key := range seasonKeys {
delete(item, key)
}
}
return item
}
// dropEmptyStringFields 删除值为空串(或全空白)的字符串字段。
//
// 用于对齐 Emby/Jellyfin 的序列化行为:这些字段在没有值时是「不存在」,
// 而不是空串。客户端普遍把空串当合法值使用,收到空串会当成非法参数。
func dropEmptyStringFields(item map[string]any, keys ...string) {
for _, key := range keys {
if value, ok := item[key].(string); ok && strings.TrimSpace(value) == "" {
delete(item, key)
}
}
}
func (e *EmbyService) resolveMediaPeople(ctx context.Context, m *model.Media) []map[string]any {
if m == nil {
return []map[string]any{}
@@ -145,43 +145,6 @@ func TestServeFileFallsBackWhenDirectResolveUnavailable(t *testing.T) {
}
}
// 客户端 API 层与播放器层 UA 不一致时(原生播放器普遍如此)不能由服务端换链后
// 直接 302 到 CDN:115 直链绑定换取时的 UA,换错就是 403。此时必须回退到同源的
// strm 端点跳转,让播放器用自己的 UA 换链。
func TestServeFileWithClientSideResolveKeepsSTRMEndpoint(t *testing.T) {
repos := directRedirectTestRepo(t)
seedCloudSTRMMedia(t, repos, "cloud-client-side", "/api/strm/play/cloud115/video.mkv?acct=a1&pickcode=pc1")
direct := "https://cdnfhnfile.115cdn.net/637b/Movie.mkv?t=1&k=sig"
resolveCalls := 0
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil).
SetStrmPlayTargetResolver(func(_ context.Context, raw, userAgent string) (*StrmPlayResult, error) {
resolveCalls++
return &StrmPlayResult{
RedirectURL: direct,
Link: &cloud.DirectLink{URL: direct, Headers: map[string]string{"User-Agent": userAgent}},
}, nil
})
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/emby/Videos/cloud-client-side/stream?api_key=jwt123", nil)
req.Header.Set("User-Agent", "RodelPlayer/2.2610.2.0 (Windows NT 10.0.26200; x64)")
req = req.WithContext(WithClientSideSTRMResolve(req.Context()))
w := httptest.NewRecorder()
if err := svc.ServeFileWithCloudMode(w, req, "cloud-client-side", CloudPlaybackModeRedirectProxy); err != nil {
t.Fatalf("ServeFileWithCloudMode: %v", err)
}
if resolveCalls != 0 {
t.Fatalf("resolver called %d times, want 0", resolveCalls)
}
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, "cdnfhnfile") {
t.Fatalf("Location = %q, must not point straight at the CDN", loc)
}
}
// 没有注入解析器(测试/精简部署)时保持原有跳转,不受本次优化影响。
func TestServeFileKeepsSTRMEndpointWithoutResolver(t *testing.T) {
repos := directRedirectTestRepo(t)
-35
View File
@@ -86,36 +86,6 @@ func setCloudRedirectNoStore(w http.ResponseWriter) {
// 由 /api/strm/play 再去换链,最坏情况只是回到改动前的行为。
const directPlayResolveTimeout = 10 * time.Second
// clientSideSTRMResolveKey 标记「本次播放由客户端自己换取 strm 直链」。
type clientSideSTRMResolveKey struct{}
// WithClientSideSTRMResolve 让本次播放跳过「服务端换链后直接 302 到 CDN」的短路,
// 改为 302 到同源的 strm 播放端点,由客户端自己的请求去换取直链。
//
// 为什么需要它:115 等网盘的直链与「换取直链时提交的 User-Agent」逐个字符严格
// 绑定,换错一个字符 CDN 一律 403。服务端短路换链用的是当前请求的 UA,而真正
// 拉流的是客户端的播放器组件;当客户端的 API 层与播放器层 UA 不一致时
// (例如小幻影视 / RodelPlayer:API 请求带 "(Windows NT 10.0.26200; x64)" 后缀、
// 播放器拉流仍是裸 UA),客户端会拿到与自身 UA 不匹配的直链,CDN 返回 403,
// 表现为「播放失败」。交给客户端自己请求同源端点换链,换链用的就是播放器真实 UA。
//
// 浏览器(含 WebView / Electron)的 XHR 与 <video> 拉流必然是同一个 UA,
// 不需要走这条路径。
func WithClientSideSTRMResolve(ctx context.Context) context.Context {
if ctx == nil {
return nil
}
return context.WithValue(ctx, clientSideSTRMResolveKey{}, true)
}
func clientSideSTRMResolve(ctx context.Context) bool {
if ctx == nil {
return false
}
enabled, _ := ctx.Value(clientSideSTRMResolveKey{}).(bool)
return enabled
}
// resolveDirectPlayTargetURL 尝试在服务端把 strm 目标解析成客户端可直接拉取的
// 最终直链,供调用方直接 302。
//
@@ -126,11 +96,6 @@ func (s *StreamService) resolveDirectPlayTargetURL(r *http.Request, raw string)
if s == nil || s.strmResolve == nil || r == nil {
return "", false
}
// 调用方声明该客户端的拉流 UA 与本次请求 UA 不保证一致(见
// WithClientSideSTRMResolve):服务端换出来的直链会绑定错误的 UA,必须放弃短路。
if clientSideSTRMResolve(r.Context()) {
return "", false
}
ctx, cancel := context.WithTimeout(r.Context(), directPlayResolveTimeout)
defer cancel()
// 必须带播放器 UA:115 直链绑定换取时的 UA,且按 UA 分键缓存,换错会拿到