mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-14 09:06:37 +08:00
优化,bug处理
This commit is contained in:
@@ -810,6 +810,20 @@ func streamHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// 远程条目可能是「整部剧/季/目录」:这类条目自身没有媒体源,远程 Emby 对它
|
||||||
|
// 们的 /Videos/{id}/stream 会直接回 500(Unable to cast object of type
|
||||||
|
// 'Series' to type 'IHasMediaSources')。网页端、外部播放器、第三方客户端都
|
||||||
|
// 可能把这种 ID 送进来,这里统一改投第一集:302 让客户端自己跟过去,播放器
|
||||||
|
// 原有的 token / proxy 查询参数一并保留。
|
||||||
|
if playable := svc.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID); playable != "" && playable != remoteID {
|
||||||
|
target := "/api/stream/" + service.EncodeEmbyRemoteID(mountID, playable)
|
||||||
|
if rawQuery := c.Request.URL.RawQuery; rawQuery != "" {
|
||||||
|
target += "?" + rawQuery
|
||||||
|
}
|
||||||
|
setRedirectNoStoreHeaders(c)
|
||||||
|
c.Redirect(http.StatusFound, target)
|
||||||
|
return
|
||||||
|
}
|
||||||
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
// 远程 Emby 条目的同源转发:VR 全景需要浏览器读帧(见 stream_proxy.go),
|
||||||
// 与挂载账号的 proxy_play 开关等价。
|
// 与挂载账号的 proxy_play 开关等价。
|
||||||
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
if mount.ProxyPlay || wantSameOriginProxy(c) {
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 首页轮播/剧集卡片交给播放器的是「整部剧」的伪装 ID,它自身没有媒体源:远程 Emby
|
||||||
|
// 对它的取流请求会回 500(Unable to cast 'Series' to 'IHasMediaSources')。这里锁定
|
||||||
|
// 播放接口的行为——改投第一集,且客户端原有的查询参数(token / profile / proxy)
|
||||||
|
// 必须原样带过去,否则播放器跟过去之后会掉鉴权。
|
||||||
|
func TestStreamRedirectsRemoteContainerToPlayableEpisode(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/emby/Users/uid-1/Items/series-100":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Id": "series-100", "Name": "某部新番", "Type": "Series", "IsFolder": true,
|
||||||
|
})
|
||||||
|
case "/emby/Users/uid-1/Items":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Items": []map[string]any{
|
||||||
|
map[string]any{
|
||||||
|
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
|
||||||
|
"ParentIndexNumber": 1, "IndexNumber": 1,
|
||||||
|
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
|
||||||
|
&model.StrmAccount{}, &model.EmbyMount{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.JWTSecret = "stream-redirect-secret"
|
||||||
|
log := zap.NewNop()
|
||||||
|
|
||||||
|
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
|
||||||
|
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote}
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"url": upstream.URL,
|
||||||
|
"api_key": "test-api-key",
|
||||||
|
"remote_user_id": "uid-1",
|
||||||
|
})
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-1"},
|
||||||
|
Name: "远程 Emby",
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
|
||||||
|
Base: model.Base{ID: "mount-1"},
|
||||||
|
AccountID: "acct-1",
|
||||||
|
RemoteViewID: "view-1",
|
||||||
|
RemoteViewName: "新番连载",
|
||||||
|
CollectionType: "tvshows",
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/stream/:id", streamHandler(svc))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(
|
||||||
|
http.MethodGet,
|
||||||
|
"/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "series-100")+"?token=tok-1&profile=p-1",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusFound {
|
||||||
|
t.Fatalf("status = %d body=%s, want 302", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
want := "/api/stream/" + service.EncodeEmbyRemoteID("mount-1", "ep-1") + "?token=tok-1&profile=p-1"
|
||||||
|
if got := w.Header().Get("Location"); got != want {
|
||||||
|
t.Fatalf("Location = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本身可播的远程条目(单集/电影)不能被改写,必须照原样进入原来的代理/直连分支。
|
||||||
|
func TestStreamKeepsPlayableRemoteItem(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
|
||||||
|
var streamCalls int
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/emby/Users/uid-1/Items/ep-1":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Id": "ep-1", "Name": "第1集", "Type": "Episode", "SeriesId": "series-100",
|
||||||
|
"ParentIndexNumber": 1, "IndexNumber": 1,
|
||||||
|
"MediaSources": []any{map[string]any{"Id": "ep-1-src"}},
|
||||||
|
})
|
||||||
|
case "/emby/Videos/ep-1/stream":
|
||||||
|
streamCalls++
|
||||||
|
w.Header().Set("Content-Type", "video/mp4")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("bytes"))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(
|
||||||
|
&model.User{}, &model.UserPermission{}, &model.Setting{}, &model.Library{}, &model.Media{},
|
||||||
|
&model.StrmAccount{}, &model.EmbyMount{},
|
||||||
|
); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Secrets.JWTSecret = "stream-keep-secret"
|
||||||
|
log := zap.NewNop()
|
||||||
|
|
||||||
|
remote := service.NewEmbyRemoteService(cfg, log, repos, service.NewCryptoService("", log))
|
||||||
|
emby := service.NewEmbyService(cfg, log, repos).SetEmbyRemote(remote)
|
||||||
|
svc := &service.Container{Cfg: cfg, Repo: repos, EmbyRemote: remote, Emby: emby}
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"url": upstream.URL,
|
||||||
|
"api_key": "test-api-key",
|
||||||
|
"remote_user_id": "uid-1",
|
||||||
|
})
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-1"},
|
||||||
|
Name: "远程 Emby",
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// proxy_play=true:直接走反向代理分支,便于断言确实取到了流。
|
||||||
|
if err := repos.EmbyMount.Create(t.Context(), &model.EmbyMount{
|
||||||
|
Base: model.Base{ID: "mount-1"},
|
||||||
|
AccountID: "acct-1",
|
||||||
|
RemoteViewID: "view-1",
|
||||||
|
RemoteViewName: "新番连载",
|
||||||
|
CollectionType: "tvshows",
|
||||||
|
ProxyPlay: true,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
router := gin.New()
|
||||||
|
router.GET("/api/stream/:id", streamHandler(svc))
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/"+service.EncodeEmbyRemoteID("mount-1", "ep-1"), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d body=%s, want 200", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := w.Body.String(); got != "bytes" {
|
||||||
|
t.Fatalf("body = %q, want the proxied stream", got)
|
||||||
|
}
|
||||||
|
if streamCalls != 1 {
|
||||||
|
t.Fatalf("upstream stream calls = %d, want 1", streamCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1587,7 +1587,22 @@ func (r *EmbyRemoteService) proxyVideoStreamOnLine(ctx context.Context, w http.R
|
|||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
if resp.StatusCode >= 400 {
|
if resp.StatusCode >= 400 {
|
||||||
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
data, _ := io.ReadAll(io.LimitReader(resp.Body, 256))
|
||||||
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, strings.TrimSpace(string(data))))
|
detail := strings.TrimSpace(string(data))
|
||||||
|
// 远程 Emby 对「整部剧/目录」这类自身没有媒体源的条目会直接 500,正文是
|
||||||
|
// 一句 .NET 异常(如 Unable to cast object of type 'Series' to type
|
||||||
|
// 'IHasMediaSources')。原样丢给用户没人看得懂,也没有排查线索——细节进日志,
|
||||||
|
// 给用户一句能照着做的提示。
|
||||||
|
if r.log != nil {
|
||||||
|
r.log.Warn("remote emby video stream rejected",
|
||||||
|
zap.String("remote_id", remoteID),
|
||||||
|
zap.Int("status", resp.StatusCode),
|
||||||
|
zap.String("detail", detail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 500 {
|
||||||
|
return redactSensitiveError(fmt.Errorf("远程 Emby 无法播放该条目(HTTP %d):请在详情页选择具体集数后重试", resp.StatusCode))
|
||||||
|
}
|
||||||
|
return redactSensitiveError(fmt.Errorf("远程 Emby 视频流失败(%d): %s", resp.StatusCode, detail))
|
||||||
}
|
}
|
||||||
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
|
for _, header := range []string{"Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "ETag", "Cache-Control"} {
|
||||||
if value := resp.Header.Get(header); value != "" {
|
if value := resp.Header.Get(header); value != "" {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newPlayableIDTestService 起一个假的远程 Emby,并建好账号。
|
||||||
|
func newPlayableIDTestService(t *testing.T, handler http.Handler) (*EmbyRemoteService, *model.StrmAccount) {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
cfg := &config.Config{}
|
||||||
|
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||||
|
remote.SetRuntimeCache(NewRuntimeCacheService(cfg, zap.NewNop()))
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"url": server.URL,
|
||||||
|
"api_key": "test-api-key",
|
||||||
|
"remote_user_id": "uid-1",
|
||||||
|
})
|
||||||
|
acct := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-1"},
|
||||||
|
Name: "远程 Emby",
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
return remote, acct
|
||||||
|
}
|
||||||
|
|
||||||
|
func playableIDTestEpisode(id string, index, sources int) map[string]any {
|
||||||
|
item := map[string]any{
|
||||||
|
"Id": id,
|
||||||
|
"Name": "第" + strconv.Itoa(index) + "集",
|
||||||
|
"Type": "Episode",
|
||||||
|
"SeriesId": "series-100",
|
||||||
|
"ParentIndexNumber": 1,
|
||||||
|
"IndexNumber": index,
|
||||||
|
}
|
||||||
|
if sources > 0 {
|
||||||
|
item["MediaSources"] = []any{map[string]any{"Id": id + "-source"}}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// 整部剧的 ID 自身没有媒体源,远程 Emby 对它的取流请求会回 500
|
||||||
|
// (Unable to cast 'Series' to 'IHasMediaSources')。这条用例保证我们会换成第一集,
|
||||||
|
// 而不是把这种 ID 直接交给远程。
|
||||||
|
func TestPlayableRemoteIDResolvesContainerToFirstEpisode(t *testing.T) {
|
||||||
|
var itemsCalls int64
|
||||||
|
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/emby/Users/uid-1/Items/series-100":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Id": "series-100", "Name": "剧一", "Type": "Series", "IsFolder": true,
|
||||||
|
})
|
||||||
|
case "/emby/Users/uid-1/Items/ep-2":
|
||||||
|
_ = json.NewEncoder(w).Encode(playableIDTestEpisode("ep-2", 2, 1))
|
||||||
|
case "/emby/Users/uid-1/Items":
|
||||||
|
atomic.AddInt64(&itemsCalls, 1)
|
||||||
|
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
|
||||||
|
t.Errorf("ParentId = %q, want series-100", got)
|
||||||
|
}
|
||||||
|
// 故意乱序,并让第一条没有媒体源:只有真按季/集排序、且检查过媒体源,
|
||||||
|
// 才会选中 ep-1。
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Items": []map[string]any{
|
||||||
|
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
|
||||||
|
playableIDTestEpisode("ep-3", 3, 1),
|
||||||
|
playableIDTestEpisode("ep-1", 1, 1),
|
||||||
|
playableIDTestEpisode("ep-2", 2, 1),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
if got := remote.PlayableRemoteID(ctx, acct, "series-100"); got != "ep-1" {
|
||||||
|
t.Fatalf("series id resolved to %q, want ep-1", got)
|
||||||
|
}
|
||||||
|
// 播放器对同一个地址会反复发 Range 请求,解析结果必须命中缓存,不再打远程。
|
||||||
|
if got := remote.PlayableRemoteID(ctx, acct, "series-100"); got != "ep-1" {
|
||||||
|
t.Fatalf("cached resolve = %q, want ep-1", got)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt64(&itemsCalls); n != 1 {
|
||||||
|
t.Fatalf("episode query issued %d times, want 1 (cached)", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本身带媒体源的条目(电影/单集)必须原样返回,不能被改写。
|
||||||
|
func TestPlayableRemoteIDKeepsPlayableItem(t *testing.T) {
|
||||||
|
var queries int64
|
||||||
|
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
atomic.AddInt64(&queries, 1)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Path != "/emby/Users/uid-1/Items/ep-2" {
|
||||||
|
t.Errorf("unexpected upstream path %s", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(playableIDTestEpisode("ep-2", 2, 1))
|
||||||
|
}))
|
||||||
|
|
||||||
|
if got := remote.PlayableRemoteID(context.Background(), acct, "ep-2"); got != "ep-2" {
|
||||||
|
t.Fatalf("playable item = %q, want ep-2", got)
|
||||||
|
}
|
||||||
|
if n := atomic.LoadInt64(&queries); n != 1 {
|
||||||
|
t.Fatalf("upstream queries = %d, want 1", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程不可用/查不到条目时保持原 ID:让原有流程去报错,别把播放地址改成空。
|
||||||
|
func TestPlayableRemoteIDKeepsOriginalOnFailure(t *testing.T) {
|
||||||
|
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
|
||||||
|
if got := remote.PlayableRemoteID(context.Background(), acct, "series-100"); got != "series-100" {
|
||||||
|
t.Fatalf("failure path = %q, want the original id", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 容器下没有任何带媒体源的子条目时同样返回原 ID(下一层会给友好报错)。
|
||||||
|
func TestPlayableRemoteIDKeepsContainerWithoutPlayableChild(t *testing.T) {
|
||||||
|
remote, acct := newPlayableIDTestService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if r.URL.Path == "/emby/Users/uid-1/Items" {
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Items": []map[string]any{
|
||||||
|
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Type": "Series", "IsFolder": true})
|
||||||
|
}))
|
||||||
|
|
||||||
|
if got := remote.PlayableRemoteID(context.Background(), acct, "series-100"); got != "series-100" {
|
||||||
|
t.Fatalf("container without playable child = %q, want the original id", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 远程条目自身没有媒体源时(例如把「整部剧」的 ID 当播放目标),远程 Emby 会回
|
||||||
|
// 500 + 一句 .NET 异常。异常原文不能直接丢给用户,但要能从日志里查得到。
|
||||||
|
func TestProxyVideoStreamOnLineHidesUpstreamException(t *testing.T) {
|
||||||
|
const upstreamBody = "Unable to cast object of type 'MediaBrowser.Controller.Entities.TV.Series' to type 'MediaBrowser.Controller.Entities.IHasMediaSources'."
|
||||||
|
|
||||||
|
var gotPath, gotStatic, gotRange, gotToken string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
gotPath = req.URL.Path
|
||||||
|
gotStatic = req.URL.Query().Get("Static")
|
||||||
|
gotRange = req.Header.Get("Range")
|
||||||
|
gotToken = req.Header.Get("X-Emby-Token")
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, _ = w.Write([]byte(upstreamBody))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc := &EmbyRemoteService{stream: upstream.Client(), log: zap.NewNop()}
|
||||||
|
cfg := &EmbyRemoteConfig{BaseURL: upstream.URL, Token: "tok-1"}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/embyremote~acct~222679", nil)
|
||||||
|
req.Header.Set("Range", "bytes=0-1023")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
|
||||||
|
err := svc.proxyVideoStreamOnLine(context.Background(), rec, req, cfg, "222679")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for upstream 500")
|
||||||
|
}
|
||||||
|
if gotPath != "/emby/Videos/222679/stream" {
|
||||||
|
t.Fatalf("upstream path = %q", gotPath)
|
||||||
|
}
|
||||||
|
if gotStatic != "true" {
|
||||||
|
t.Fatalf("Static = %q, want true", gotStatic)
|
||||||
|
}
|
||||||
|
if gotRange != "bytes=0-1023" {
|
||||||
|
t.Fatalf("Range = %q, want forwarded range", gotRange)
|
||||||
|
}
|
||||||
|
if gotToken != "tok-1" {
|
||||||
|
t.Fatalf("X-Emby-Token = %q", gotToken)
|
||||||
|
}
|
||||||
|
if rec.Body.Len() != 0 {
|
||||||
|
t.Fatalf("nothing should be written to the client on upstream failure, got %q", rec.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := err.Error()
|
||||||
|
if strings.Contains(msg, "Unable to cast") || strings.Contains(msg, "MediaBrowser") {
|
||||||
|
t.Fatalf("raw upstream exception must not leak to clients: %q", msg)
|
||||||
|
}
|
||||||
|
if !strings.Contains(msg, "无法播放该条目") {
|
||||||
|
t.Fatalf("error should tell the user what to do, got %q", msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4xx(例如远程条目被删除/无权限)保持原有措辞,带着上游说明便于排查。
|
||||||
|
func TestProxyVideoStreamOnLineKeepsClientErrorDetail(t *testing.T) {
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
_, _ = w.Write([]byte("Item not found"))
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
svc := &EmbyRemoteService{stream: upstream.Client(), log: zap.NewNop()}
|
||||||
|
cfg := &EmbyRemoteConfig{BaseURL: upstream.URL, Token: "tok-1"}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/stream/embyremote~acct~404", nil)
|
||||||
|
err := svc.proxyVideoStreamOnLine(context.Background(), httptest.NewRecorder(), req, cfg, "404")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error for upstream 404")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "404") || !strings.Contains(err.Error(), "Item not found") {
|
||||||
|
t.Fatalf("client error detail should be kept, got %q", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -820,6 +820,123 @@ func (r *EmbyRemoteService) remoteItemSeriesID(ctx context.Context, acct *model.
|
|||||||
return remoteItemString(out, "SeriesId")
|
return remoteItemString(out, "SeriesId")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlayableRemoteID 把「自身没有媒体源」的远程条目换成可直接播放的条目 ID。
|
||||||
|
//
|
||||||
|
// 远程 Emby 只为带媒体源的条目提供 /Videos/{id}/stream:拿整部剧/季/目录的 ID 去
|
||||||
|
// 请求,它会回 500 加一句 .NET 异常(Unable to cast object of type 'Series' to
|
||||||
|
// type 'IHasMediaSources')。首页轮播、剧集库卡片、外部播放器与第三方客户端都可能
|
||||||
|
// 把这种 ID 交过来,统一在这里回落到第一集,保证拿到的 ID 真能取到流。
|
||||||
|
//
|
||||||
|
// 条目本身可播、或解析失败时返回原 ID(调用方拿到后用原逻辑处理即可,无需区分错误)。
|
||||||
|
func (r *EmbyRemoteService) PlayableRemoteID(ctx context.Context, acct *model.StrmAccount, remoteID string) string {
|
||||||
|
remoteID = strings.TrimSpace(remoteID)
|
||||||
|
if r == nil || acct == nil || remoteID == "" {
|
||||||
|
return remoteID
|
||||||
|
}
|
||||||
|
cfg, err := r.remoteConfigWithToken(ctx, acct)
|
||||||
|
if err != nil {
|
||||||
|
return remoteID
|
||||||
|
}
|
||||||
|
cacheKey := r.remoteCacheKey("playable-remote-id", acct.ID, remoteID)
|
||||||
|
if r.cache != nil {
|
||||||
|
var cached string
|
||||||
|
// 播放器会对同一个地址发很多次 Range 请求,判定结果必须走缓存,否则每次拖动
|
||||||
|
// 进度条都要多打两次远程 Emby。缓存比常规媒体缓存长:条目可播性是静态属性。
|
||||||
|
if r.cache.GetJSON(ctx, cacheKey, &cached) {
|
||||||
|
if cached == "" {
|
||||||
|
return remoteID
|
||||||
|
}
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved := r.resolvePlayableRemoteID(ctx, acct, cfg, remoteID)
|
||||||
|
// 解析失败不写缓存:远程抖动时下次照样重试,而不是把「播不了」记住十分钟。
|
||||||
|
if r.cache != nil && resolved != "" {
|
||||||
|
r.cache.SetJSON(ctx, cacheKey, resolved, playableRemoteIDCacheTTL)
|
||||||
|
}
|
||||||
|
if resolved == "" {
|
||||||
|
return remoteID
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
// playableRemoteIDCacheTTL 是「条目可播性」判定的缓存时长。
|
||||||
|
const playableRemoteIDCacheTTL = 10 * time.Minute
|
||||||
|
|
||||||
|
// resolvePlayableRemoteID 返回 remoteID 对应的可播条目:本身带媒体源时原样返回,
|
||||||
|
// 容器条目(整部剧/季/目录)返回排在最前的、确实带媒体源的子条目。
|
||||||
|
func (r *EmbyRemoteService) resolvePlayableRemoteID(ctx context.Context, acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID string) string {
|
||||||
|
var item map[string]any
|
||||||
|
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items/"+url.PathEscape(remoteID), nil, &item); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if remoteItemHasMediaSources(item) || !remoteItemIsContainer(item) {
|
||||||
|
return remoteID
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("ParentId", remoteID)
|
||||||
|
q.Set("Recursive", "true")
|
||||||
|
q.Set("IncludeItemTypes", "Episode,Movie,Video")
|
||||||
|
q.Set("Fields", "MediaSources")
|
||||||
|
q.Set("SortBy", "ParentIndexNumber,IndexNumber,SortName")
|
||||||
|
q.Set("Limit", "50")
|
||||||
|
var body struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
}
|
||||||
|
if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程 SortBy 只覆盖它认识的字段(不同 Emby/Jellyfin 版本支持度不一),
|
||||||
|
// 这里再按季/集排一次,确保拿到的是第一集而不是服务端返回顺序里的第一条。
|
||||||
|
children := body.Items
|
||||||
|
sort.SliceStable(children, func(i, j int) bool {
|
||||||
|
if a, b := remoteItemInt(children[i], "ParentIndexNumber"), remoteItemInt(children[j], "ParentIndexNumber"); a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
if a, b := remoteItemInt(children[i], "IndexNumber"), remoteItemInt(children[j], "IndexNumber"); a != b {
|
||||||
|
return a < b
|
||||||
|
}
|
||||||
|
return remoteItemString(children[i], "SortName") < remoteItemString(children[j], "SortName")
|
||||||
|
})
|
||||||
|
for _, child := range children {
|
||||||
|
if id := remoteItemString(child, "Id"); id != "" && remoteItemHasMediaSources(child) {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteItemHasMediaSources 该条目是否自带媒体源(即能直接取流)。
|
||||||
|
func remoteItemHasMediaSources(item map[string]any) bool {
|
||||||
|
if item == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch sources := item["MediaSources"].(type) {
|
||||||
|
case []any:
|
||||||
|
return len(sources) > 0
|
||||||
|
case []map[string]any:
|
||||||
|
return len(sources) > 0
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteItemIsContainer 该条目是否是「装别人」的容器:整部剧/季/目录/合集。
|
||||||
|
func remoteItemIsContainer(item map[string]any) bool {
|
||||||
|
switch remoteItemString(item, "Type") {
|
||||||
|
case "Series", "Season", "Folder", "BoxSet", "CollectionFolder", "AggregateFolder", "Playlist":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// 类型缺失时退回 IsFolder(Emby 的目录类条目都会带)。
|
||||||
|
if _, ok := item["Type"]; !ok {
|
||||||
|
if isFolder, okBool := item["IsFolder"].(bool); okBool {
|
||||||
|
return isFolder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 远程 item JSON 取值辅助 ────────────────────────────────────────────────
|
// ─── 远程 item JSON 取值辅助 ────────────────────────────────────────────────
|
||||||
|
|
||||||
func anyString(v any) string {
|
func anyString(v any) string {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { useAuthStore } from '../stores/auth'
|
|||||||
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
|
||||||
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
|
||||||
import { formatEpisodeLabel } from '../utils/episodeNumber'
|
import { formatEpisodeLabel } from '../utils/episodeNumber'
|
||||||
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
import { autoPlayEpisodeFor, mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||||
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
|
import { DEFAULT_PLAYBACK_RATE, normalizePlaybackRate } from '../utils/playbackRate'
|
||||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||||
@@ -1681,6 +1681,17 @@ export function PlayerPage() {
|
|||||||
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
|
return playlistEpisodes.findIndex((e) => mediaVersionMatches(e, media.id))
|
||||||
}, [media, playlistEpisodes])
|
}, [media, playlistEpisodes])
|
||||||
|
|
||||||
|
// 用剧集 ID 打开播放器时(首页轮播、剧集库卡片、剧集详情页的播放按钮),先落到第一集:
|
||||||
|
// 这类条目没有可播文件,直接请求 /api/stream/<剧集ID> 会被远程 Emby 以 500 打回。
|
||||||
|
useEffect(() => {
|
||||||
|
const target = autoPlayEpisodeFor(media, id, playlistEpisodes)
|
||||||
|
if (!target) return
|
||||||
|
navigate(
|
||||||
|
{ pathname: `/play/${target.id}`, search: location.search },
|
||||||
|
{ replace: true, state: location.state },
|
||||||
|
)
|
||||||
|
}, [media, id, playlistEpisodes, navigate, location.search, location.state])
|
||||||
|
|
||||||
const prevEpisode = useMemo(() => {
|
const prevEpisode = useMemo(() => {
|
||||||
if (currentEpisodeIndex > 0) {
|
if (currentEpisodeIndex > 0) {
|
||||||
return playlistEpisodes[currentEpisodeIndex - 1]
|
return playlistEpisodes[currentEpisodeIndex - 1]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
autoPlayEpisodeFor,
|
||||||
isStrmMedia,
|
isStrmMedia,
|
||||||
mediaVersionFileName,
|
mediaVersionFileName,
|
||||||
mediaVersionLabel,
|
mediaVersionLabel,
|
||||||
@@ -64,4 +65,25 @@ check('strm filename drops outer extension', mediaVersionFileName(cloudRow) ===
|
|||||||
check('missing path has no filename', mediaVersionFileName(media({ path: '' })) === '')
|
check('missing path has no filename', mediaVersionFileName(media({ path: '' })) === '')
|
||||||
check('isStrmMedia detects strm url', isStrmMedia(cloudRow))
|
check('isStrmMedia detects strm url', isStrmMedia(cloudRow))
|
||||||
|
|
||||||
|
// 首页轮播/剧集卡片交给播放器的是「整部剧」ID:选集列表里没有它自己,
|
||||||
|
// 播放器要自动落到第一集,否则 /api/stream/<剧集ID> 会被远程 Emby 打回 500。
|
||||||
|
const series = media({ id: 'embyremote~mount-1~222679', title: '某部新番', path: '/anime/某部新番', size_bytes: 0 })
|
||||||
|
const ep1 = media({ id: 'embyremote~mount-1~222681', path: '/anime/某部新番/Season 1/S1E01.mp4', size_bytes: 299580311 })
|
||||||
|
const ep2 = media({ id: 'embyremote~mount-1~222683', path: '/anime/某部新番/Season 1/S1E02.mp4', size_bytes: 299580312 })
|
||||||
|
|
||||||
|
check('series falls back to its first episode', autoPlayEpisodeFor(series, series.id, [ep1, ep2])?.id === ep1.id)
|
||||||
|
check('playable episode stays untouched', autoPlayEpisodeFor(ep1, ep1.id, [ep1, ep2]) === null)
|
||||||
|
check('single-item movie list stays untouched', autoPlayEpisodeFor(ep1, ep1.id, [{ ...ep1 }]) === null)
|
||||||
|
check('waits until the requested item is loaded', autoPlayEpisodeFor(null, series.id, [ep1]) === null)
|
||||||
|
check('ignores a response for another id', autoPlayEpisodeFor(ep2, series.id, [ep1]) === null)
|
||||||
|
check('empty episode list has no fallback', autoPlayEpisodeFor(series, series.id, []) === null)
|
||||||
|
check(
|
||||||
|
'entry present as another version stays untouched',
|
||||||
|
autoPlayEpisodeFor(
|
||||||
|
media({ id: 'v-4k' }),
|
||||||
|
'v-4k',
|
||||||
|
[media({ id: 'v-4k', versions: [local4k, local1080] })],
|
||||||
|
) === null,
|
||||||
|
)
|
||||||
|
|
||||||
console.log('mediaVersion.test.ts: ok')
|
console.log('mediaVersion.test.ts: ok')
|
||||||
|
|||||||
@@ -195,6 +195,32 @@ export function mediaVersionSourceLabel(media: Media): string {
|
|||||||
return isStrmMedia(media) ? '云端直链' : '本地文件'
|
return isStrmMedia(media) ? '云端直链' : '本地文件'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 挑出「播放器该自动跳转到的那一集」。
|
||||||
|
*
|
||||||
|
* 传给 /play/:id 的可能是「整部剧」这类自身没有可播文件的条目——首页轮播、远程 Emby
|
||||||
|
* 剧集库的卡片、剧集详情页的播放按钮都会把剧集 ID 直接交给播放器。这种条目的选集接口
|
||||||
|
* 返回的是它下面的剧集,列表里没有它自己;若直接拿剧集 ID 去请求
|
||||||
|
* /api/stream/<剧集ID>,远程 Emby 会返回 HTTP 500(Unable to cast object of type
|
||||||
|
* 'Series' to type 'IHasMediaSources'),用户看到的就是「播放失败」。此时换成第一集,
|
||||||
|
* 播放、进度、上下集都落在真正可播的条目上。
|
||||||
|
*
|
||||||
|
* 返回 null 表示无需跳转:条目还没加载完、条目本身可播(含以其它版本形式出现在列表里)、
|
||||||
|
* 或该条目下没有任何可选剧集。
|
||||||
|
*/
|
||||||
|
export function autoPlayEpisodeFor(
|
||||||
|
media: Media | null | undefined,
|
||||||
|
requestedId: string,
|
||||||
|
episodes: Media[],
|
||||||
|
): Media | null {
|
||||||
|
if (!media || !requestedId || media.id !== requestedId) return null
|
||||||
|
if (episodes.length === 0) return null
|
||||||
|
if (episodes.some((episode) => mediaVersionMatches(episode, media.id))) return null
|
||||||
|
const first = episodes[0]
|
||||||
|
if (!first?.id || first.id === media.id) return null
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
/** 版本文件名(去掉目录,并剥掉 .strm 外层扩展名),用作版本选项的次要说明。 */
|
/** 版本文件名(去掉目录,并剥掉 .strm 外层扩展名),用作版本选项的次要说明。 */
|
||||||
export function mediaVersionFileName(media: Media): string {
|
export function mediaVersionFileName(media: Media): string {
|
||||||
const path = (media.path || '').replace(/\\/g, '/').trim()
|
const path = (media.path || '').replace(/\\/g, '/').trim()
|
||||||
|
|||||||
Reference in New Issue
Block a user