Compare commits

...

2 Commits

Author SHA1 Message Date
truewhile 1e6909ddbf 优化播放 2026-09-30 11:20:27 +08:00
truewhile 8136e6ecb1 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 是一致的(剧集走服务端换链后的一跳直连可以正常解码播放),
不需要为此牺牲起播速度。
2026-09-28 22:51:40 +08:00
14 changed files with 361 additions and 272 deletions
-2
View File
@@ -1,2 +0,0 @@
'Get-Content' is not recognized as an internal or external command,
operable program or batch file.
+9 -32
View File
@@ -86,7 +86,9 @@ func embyPrewarmPlaybackTargets(svc *service.Container, c *gin.Context, out map[
if err != nil || m == nil {
return
}
raw := strings.TrimSpace(m.STRMURL)
// 与播放路径(StreamService.ServeFileWithCloudMode)同一套目标解析:
// STRMURL 为空时回读 .strm 文件内容,预热才能覆盖同一批条目。
raw := service.MediaSTRMTarget(m)
if raw == "" || !service.IsStrmMediaRow(m) {
return
}
@@ -348,12 +350,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 +389,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
@@ -434,9 +411,10 @@ func embyVideoHLSPlaylistHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
// 只需确认媒体行存在且对当前用户可见;Emby.Item 会构建完整条目载荷,
// 转码播放下每个分片请求都跑一遍太浪费。
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
@@ -461,9 +439,8 @@ func embyVideoHLSSegmentHandler(svc *service.Container) gin.HandlerFunc {
c.Status(http.StatusNotFound)
return
}
uid := embyUserID(c)
item, err := svc.Emby.Item(c.Request.Context(), c.Param("id"), uid)
if err != nil || item == nil || svc.Stream == nil {
m, err := svc.Repo.Media.FindByID(c.Request.Context(), c.Param("id"))
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) || svc.Stream == nil {
c.Status(http.StatusNotFound)
return
}
@@ -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)
}
}
}
+12
View File
@@ -12,6 +12,8 @@ import (
"net/url"
"strings"
"go.uber.org/zap"
"github.com/gin-gonic/gin"
"github.com/truewhile/MeBox/internal/middleware"
@@ -173,10 +175,20 @@ func externalPlaybackToken(c *gin.Context, svc *service.Container, mediaID strin
uid, _ := c.Get(middleware.CtxUserID)
u, err := svc.Repo.User.FindByID(c.Request.Context(), toString(uid))
if err != nil || u == nil {
if svc.Log != nil {
svc.Log.Warn("external playback token: user lookup failed",
zap.String("media_id", mediaID), zap.Error(err))
}
return ""
}
token, err := svc.Auth.IssueExternalPlaybackToken(u, mediaID, durationSec)
if err != nil {
// 静默返回空串会让 stream_url 变成 ?token=(空)、外部播放器只看到
// 401 无从排查,至少在服务端日志里留下原因。
if svc.Log != nil {
svc.Log.Warn("issue external playback token failed",
zap.String("media_id", mediaID), zap.Error(err))
}
return ""
}
return token
+23 -9
View File
@@ -128,16 +128,16 @@ func (p *Cloud115HLSProxy) ServeChild(ctx context.Context, w http.ResponseWriter
strings.Contains(contentType, "application/vnd.apple.mpegurl")
if !isPlaylist {
// Content-Type 缺失时用前 4KB 内容嗅探是否为播放列表;无论结果如何
// 都要把已读前缀接回 body,避免分片/播放列表丢开头字节。
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 4096))
if readErr != nil {
return readErr
}
if strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") {
isPlaylist = true
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
} else {
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
resp.Body = io.NopCloser(io.MultiReader(strings.NewReader(string(body)), resp.Body))
}
copyUpstreamHeaders(w, resp, isPlaylist)
@@ -195,9 +195,17 @@ func (p *Cloud115HLSProxy) storeSession(session *cloud115HLSSession) {
}
}
if len(p.sessions) >= cloud115HLSMaxSessions {
for id := range p.sessions {
delete(p.sessions, id)
break
// ExpiresAt 恒为「最近一次活跃时间 + TTL」(lookup 会滑动续期),
// 因此最小者即最久未活跃的会话,淘汰它对正在播放的影响最小。
evictID := ""
var evictAt time.Time
for id, existing := range p.sessions {
if evictID == "" || existing.ExpiresAt.Before(evictAt) {
evictID, evictAt = id, existing.ExpiresAt
}
}
if evictID != "" {
delete(p.sessions, evictID)
}
}
session.ExpiresAt = now.Add(cloud115HLSSessionTTL)
@@ -210,9 +218,15 @@ func (p *Cloud115HLSProxy) lookup(sessionID, key string) (*cloud115HLSSession, s
}
p.mu.Lock()
session := p.sessions[sessionID]
if session != nil && time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
if session != nil {
if time.Now().After(session.ExpiresAt) {
delete(p.sessions, sessionID)
session = nil
} else {
// 滑动续期:VOD 点播整个播放期间只有分片请求,master 只在起播时取
// 一次,不续期的话会话会在 TTL 后被删,长视频播放到一半必然断流。
session.ExpiresAt = time.Now().Add(cloud115HLSSessionTTL)
}
}
p.mu.Unlock()
if session == nil {
@@ -3,6 +3,7 @@ package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -56,6 +57,67 @@ https://cpats01.115.com/seg.ts?x=1
}
}
// 长视频 VOD 播放期间只有分片请求经过 lookup,会话必须随活动滑动续期,
// 否则固定 30 分钟 TTL 一到就会在中途 404 断流。
func TestCloud115HLSProxyLookupRenewsSession(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
session := &cloud115HLSSession{
ID: "sess",
MediaID: "media-1",
entries: map[string]string{"e1": "https://cpats01.115.com/v.m3u8"},
}
proxy.storeSession(session)
// 模拟播放进行到第 29 分钟:距过期只剩 1 分钟。
session.ExpiresAt = time.Now().Add(time.Minute)
if _, _, ok := proxy.lookup(session.ID, "e1"); !ok {
t.Fatal("active session should still resolve before expiry")
}
if remaining := time.Until(session.ExpiresAt); remaining < cloud115HLSSessionTTL-10*time.Second {
t.Fatalf("session not renewed on activity, remaining = %v", remaining)
}
// 真正过期(期间无任何活动)的会话仍要被清理。
session.ExpiresAt = time.Now().Add(-time.Minute)
if _, _, ok := proxy.lookup(session.ID, "e1"); ok {
t.Fatal("expired session should not resolve")
}
proxy.mu.Lock()
_, exists := proxy.sessions[session.ID]
proxy.mu.Unlock()
if exists {
t.Fatal("expired session should be evicted from the map")
}
}
// 满员驱逐必须淘汰最久未活跃的会话(ExpiresAt = 最近活跃 + TTL,最小者即
// 最久未活跃),而不是随机挑一个——随机驱逐可能正好杀掉正在播放的会话。
func TestCloud115HLSProxyEvictsLeastRecentlyActiveSession(t *testing.T) {
proxy := &Cloud115HLSProxy{sessions: map[string]*cloud115HLSSession{}}
first := &cloud115HLSSession{ID: "active", MediaID: "media-1", entries: map[string]string{}}
proxy.storeSession(first)
for i := 0; i < cloud115HLSMaxSessions-1; i++ {
proxy.storeSession(&cloud115HLSSession{ID: fmt.Sprintf("s-%03d", i), MediaID: "media-1", entries: map[string]string{}})
}
// 人为拉开活跃时间差,避免依赖时钟精度:active 最近活跃,s-000 最久未活跃。
base := time.Now()
first.ExpiresAt = base.Add(cloud115HLSSessionTTL)
proxy.sessions["s-000"].ExpiresAt = base.Add(cloud115HLSSessionTTL - time.Hour)
proxy.storeSession(&cloud115HLSSession{ID: "newcomer", MediaID: "media-1", entries: map[string]string{}})
proxy.mu.Lock()
_, activeExists := proxy.sessions["active"]
_, staleExists := proxy.sessions["s-000"]
proxy.mu.Unlock()
if !activeExists {
t.Fatal("recently active session must survive eviction")
}
if staleExists {
t.Fatal("least recently active session should have been evicted")
}
}
func TestIsAllowed115UpstreamHost(t *testing.T) {
for _, host := range []string{"videoplay.115.com", "cpats01.115.com", "cdn.115cdn.net"} {
if !isAllowed115UpstreamHost(host) {
+12
View File
@@ -220,6 +220,11 @@ func (s *Cloud115PlaybackService) ResolveCloud115URL(ctx context.Context, mediaI
return strings.TrimSpace(item.URL), data, nil
}
// cloud115PushStateRetention pushState 条目保留上限。成功条目 3 小时内用于
// 去重;超过一倍余量即为死数据,触发转码请求时顺带清理,防止 map 随观看过的
// 条目数无限增长。
const cloud115PushStateRetention = 6 * time.Hour
func (s *Cloud115PlaybackService) ensureCloudTranscode(
ctx context.Context,
accountID, pickCode string,
@@ -230,6 +235,13 @@ func (s *Cloud115PlaybackService) ensureCloudTranscode(
now := time.Now()
s.mu.Lock()
if len(s.pushState) > 0 {
for k, v := range s.pushState {
if now.Sub(v.at) > cloud115PushStateRetention {
delete(s.pushState, k)
}
}
}
if last, ok := s.pushState[key]; ok {
ttl := 2 * time.Minute
if last.success {
@@ -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)
+29 -40
View File
@@ -22,6 +22,25 @@ func (s *StreamService) ServeFile(w http.ResponseWriter, r *http.Request, mediaI
return s.ServeFileWithCloudMode(w, r, mediaID, "")
}
// MediaSTRMTarget 返回媒体行固化的 STRM 播放目标。STRMURL 为空时回读本地
// .strm 文件内容兜底(扫描时内容解析失败的行只剩 Container=strm + Path),
// 仍拿不到返回空串。
func MediaSTRMTarget(m *model.Media) string {
if m == nil {
return ""
}
if raw := strings.TrimSpace(m.STRMURL); raw != "" {
return raw
}
path := strings.TrimSpace(m.Path)
if strings.HasSuffix(strings.ToLower(path), ".strm") {
if target, err := readLocalSTRMTarget(path); err == nil {
return strings.TrimSpace(target)
}
}
return ""
}
func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Request, mediaID, cloudMode string) error {
m, err := s.repo.Media.FindByID(r.Context(), mediaID)
if err != nil {
@@ -30,7 +49,8 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
if m == nil {
return ErrMediaNotFound
}
if strmURL := strings.TrimSpace(m.STRMURL); strmURL != "" && playableSTRMTarget(r.Context(), s.repo, strmURL, m) {
strmURL := MediaSTRMTarget(m)
if strmURL != "" && playableSTRMTarget(r.Context(), s.repo, strmURL, m) {
if !cloudPlaybackModeEnabled(r.Context(), s.repo, cloudMode) {
return ErrCloudPlaybackDisabled
}
@@ -50,10 +70,14 @@ func (s *StreamService) ServeFileWithCloudMode(w http.ResponseWriter, r *http.Re
http.Redirect(w, r, absoluteInternalRedirect(target, r), http.StatusFound)
return nil
}
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(m.Path)), "cloud://") {
// 云盘媒体没有本地文件可回退;走到这里说明 STRM 播放被关闭或
// STRMURL 缺失。返回明确错误而不是笼统的「文件不存在」,
// 处理器据此回 502 + 原因,方便用户在播放器/日志里定位。
pathLower := strings.ToLower(strings.TrimSpace(m.Path))
if strings.HasPrefix(pathLower, "cloud://") ||
strings.HasSuffix(pathLower, ".strm") ||
strings.EqualFold(strings.TrimSpace(m.Container), "strm") {
// 云盘/STRM 媒体没有本地视频文件可回退;走到这里说明 STRM 播放被关闭
// 或播放目标缺失(.strm 内容解析失败)。绝不能把 .strm 文本文件当视频
// 流出去,返回明确错误而不是笼统的「文件不存在」,处理器据此回
// 502 + 原因,方便用户在播放器/日志里定位。
return ErrCloudPlaybackUnavailable
}
f, err := os.Open(m.Path)
@@ -86,36 +110,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 +120,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 分键缓存,换错会拿到
+68
View File
@@ -5,6 +5,8 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
@@ -251,6 +253,72 @@ func TestServeFilePassesThroughForeignInstanceSTRMURL(t *testing.T) {
// 本机自己生成的 .strm 在换了域名/IP 之后仍要认领:host 对不上,但 acct 是本机
// 网盘账号,于是按当前请求 host 相对化,保持可播放。
// 扫描时 .strm 内容解析失败会产生 Container=strm 但 STRMURL 为空的行:
// 播放路径必须回读 .strm 文件内容兜底(与 MediaPlaybackProvider 等一致),
// 而不是把 .strm 文本文件当视频流返回。
func TestServeFileFallsBackToStrmFileContentWhenSTRMURLEmpty(t *testing.T) {
repos := newStreamTestRepo(t)
dir := t.TempDir()
strmPath := filepath.Join(dir, "Movie.strm")
target := "https://cdn.example.test/Movie.mkv?sign=direct"
if err := os.WriteFile(strmPath, []byte(target+"\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "strm-empty-url"},
Title: "STRM Empty URL",
Path: strmPath,
Container: "strm",
STRMURL: "",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/strm-empty-url?token=jwt123", nil)
w := httptest.NewRecorder()
if err := svc.ServeFile(w, req, "strm-empty-url"); err != nil {
t.Fatalf("empty-STRMURL .strm row should fall back to file content: %v", err)
}
if w.Code != http.StatusFound {
t.Fatalf("status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != target {
t.Fatalf("Location = %q, want %q", loc, target)
}
}
// .strm 内容完全解析不出播放目标时,返回明确的 502 错误,绝不把 .strm 文本
// 文件本身当视频流吐给播放器。
func TestServeFileRejectsStrmRowWithUnresolvableTarget(t *testing.T) {
repos := newStreamTestRepo(t)
dir := t.TempDir()
strmPath := filepath.Join(dir, "Broken.strm")
if err := os.WriteFile(strmPath, []byte("# 只有注释,没有可用播放地址\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := repos.DB.Create(&model.Media{
Base: model.Base{ID: "strm-broken"},
Title: "STRM Broken",
Path: strmPath,
Container: "strm",
STRMURL: "",
}).Error; err != nil {
t.Fatal(err)
}
svc := NewStreamService(&config.Config{}, zap.NewNop(), repos, nil)
req := httptest.NewRequest(http.MethodGet, "http://nas.local:18080/api/stream/strm-broken", nil)
w := httptest.NewRecorder()
err := svc.ServeFile(w, req, "strm-broken")
if !errors.Is(err, ErrCloudPlaybackUnavailable) {
t.Fatalf("error = %v, want ErrCloudPlaybackUnavailable", err)
}
if w.Code != http.StatusOK {
t.Fatalf("no bytes should be written on error, status = %d", w.Code)
}
}
func TestServeFileRealignsOwnSTRMURLOtherHost(t *testing.T) {
repos := repository.New(newServiceTestDB(t, &model.Media{}, &model.Setting{}, &model.StrmAccount{}))
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
+1 -1
View File
@@ -286,7 +286,7 @@ func (s *StrmService) ProxyDirect(ctx context.Context, w http.ResponseWriter, r
req.Header.Set("User-Agent", ua)
}
}
resp, err := s.http.Do(req)
resp, err := s.streamHTTP.Do(req)
if err != nil {
return err
}
+28 -13
View File
@@ -11,6 +11,7 @@ import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"os"
"path/filepath"
@@ -81,14 +82,15 @@ var StrmAccountSecretKeys = []string{"cookie", "password", "token", "access_toke
// StrmService 提供 STRM 管理的能力。
type StrmService struct {
log *zap.Logger
repo *repository.Container
cfg *config.Config
crypto *CryptoService
http *http.Client
stopOnce sync.Once
stopCh chan struct{}
baseCtx context.Context // 服务级长期上下文(同步/队列不随 HTTP 请求取消)
log *zap.Logger
repo *repository.Container
cfg *config.Config
crypto *CryptoService
http *http.Client
streamHTTP *http.Client // 视频流转发专用:无总超时(见 ProxyDirect)
stopOnce sync.Once
stopCh chan struct{}
baseCtx context.Context // 服务级长期上下文(同步/队列不随 HTTP 请求取消)
mu sync.Mutex
running map[string]context.CancelFunc // sync path id -> cancel
@@ -161,11 +163,24 @@ func (s *StrmService) releaseDownloadSlot(provider string) {
// NewStrmService constructs the STRM service.
func NewStrmService(cfg *config.Config, log *zap.Logger, repos *repository.Container, crypto *CryptoService) *StrmService {
return &StrmService{
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
log: log,
repo: repos,
cfg: cfg,
crypto: crypto,
http: &http.Client{Timeout: 90 * time.Second},
// 视频流转发不能用带总超时的 client:http.Client.Timeout 覆盖整个
// 响应体读取,长视频必然超过 90s 被硬切。传输生命周期由请求 ctx
// (客户端断开即取消)控制,这里只保留建连/响应头阶段的兜底超时。
streamHTTP: &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 15 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
ForceAttemptHTTP2: true,
TLSHandshakeTimeout: 10 * time.Second,
ResponseHeaderTimeout: 30 * time.Second,
IdleConnTimeout: 90 * time.Second,
},
},
stopCh: make(chan struct{}),
baseCtx: context.Background(),
running: map[string]context.CancelFunc{},