mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
Compare commits
1 Commits
mebox-v0.1.155
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8136e6ecb1 |
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 分键缓存,换错会拿到
|
||||
|
||||
Reference in New Issue
Block a user