mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-14 09:06:37 +08:00
优化
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDanmakuRemoteResolverTest 起一个假的远程 Emby(账号 + 挂载齐备),返回接好
|
||||||
|
// 弹幕解析器的 builder。
|
||||||
|
func newDanmakuRemoteResolverTest(t *testing.T, handler http.Handler) *serviceContainerBuilder {
|
||||||
|
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",
|
||||||
|
})
|
||||||
|
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.Fatalf("create account: %v", 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.Fatalf("create mount: %v", err)
|
||||||
|
}
|
||||||
|
return &serviceContainerBuilder{
|
||||||
|
cfg: cfg,
|
||||||
|
log: zap.NewNop(),
|
||||||
|
repos: repos,
|
||||||
|
c: &Container{Cfg: cfg, Log: zap.NewNop(), Repo: repos, EmbyRemote: remote},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func danmakuResolverTestEpisode(id string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"Id": id,
|
||||||
|
"Name": "第 1 集",
|
||||||
|
"Type": "Episode",
|
||||||
|
"Path": "/media/anime/PSYREN/" + id + ".mkv",
|
||||||
|
"MediaSources": []any{
|
||||||
|
map[string]any{"Id": id + "-src", "Container": "mkv", "Size": int64(1 << 20)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 目录类条目(整部剧/季)自身没有媒体源,拿它的 ID 去请求远程 /Videos/{id}/stream 只会
|
||||||
|
// 回 500,哈希层于是每次都白白跳过。这条用例保证弹幕解析器跟随 /api/stream 的规则先
|
||||||
|
// 落到第一集:哈希算的是真正要播的那条流。
|
||||||
|
func TestResolveDanmakuRemoteMediaUsesPlayableChildForContainer(t *testing.T) {
|
||||||
|
var requested []string
|
||||||
|
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requested = append(requested, r.URL.Path)
|
||||||
|
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": "PSYREN", "Type": "Series", "IsFolder": true,
|
||||||
|
})
|
||||||
|
case "/emby/Users/uid-1/Items":
|
||||||
|
if got := r.URL.Query().Get("ParentId"); got != "series-100" {
|
||||||
|
t.Errorf("ParentId = %q, want series-100", got)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Items": []map[string]any{
|
||||||
|
{"Id": "season-1", "Type": "Season", "IndexNumber": 1},
|
||||||
|
danmakuResolverTestEpisode("ep-1"),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
case "/emby/Users/uid-1/Items/ep-1":
|
||||||
|
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-1"))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
media, streamURL, err := b.resolveDanmakuRemoteMedia(
|
||||||
|
context.Background(), EncodeEmbyRemoteID("mount-1", "series-100"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve failed: %v", err)
|
||||||
|
}
|
||||||
|
if media == nil {
|
||||||
|
t.Fatal("resolve returned no media metadata")
|
||||||
|
}
|
||||||
|
if !strings.Contains(streamURL, "/Videos/ep-1/stream") {
|
||||||
|
t.Fatalf("stream url = %q, want the playable child ep-1", streamURL)
|
||||||
|
}
|
||||||
|
if strings.Contains(streamURL, "series-100") {
|
||||||
|
t.Fatalf("stream url = %q, must not point at the container id (remote Emby answers 500)", streamURL)
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(requested, " "), "/Items/ep-1") {
|
||||||
|
t.Fatalf("metadata must come from the playable child, requests = %v", requested)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本身可播的条目(单集/电影)不能被改写:直接取它自己的流地址。
|
||||||
|
func TestResolveDanmakuRemoteMediaKeepsPlayableItem(t *testing.T) {
|
||||||
|
b := newDanmakuRemoteResolverTest(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/ep-2" {
|
||||||
|
t.Errorf("unexpected upstream path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(danmakuResolverTestEpisode("ep-2"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
media, streamURL, err := b.resolveDanmakuRemoteMedia(
|
||||||
|
context.Background(), EncodeEmbyRemoteID("mount-1", "ep-2"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve failed: %v", err)
|
||||||
|
}
|
||||||
|
if media == nil {
|
||||||
|
t.Fatal("resolve returned no media metadata")
|
||||||
|
}
|
||||||
|
if !strings.Contains(streamURL, "/Videos/ep-2/stream") {
|
||||||
|
t.Fatalf("stream url = %q, want /Videos/ep-2/stream", streamURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不是远程伪装 ID 时直接报错,不去猜一个可以拉取的地址。
|
||||||
|
func TestResolveDanmakuRemoteMediaRejectsPlainID(t *testing.T) {
|
||||||
|
b := newDanmakuRemoteResolverTest(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
t.Error("plain local ids must not reach the remote Emby")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}))
|
||||||
|
|
||||||
|
if _, _, err := b.resolveDanmakuRemoteMedia(context.Background(), "local-media-1"); err == nil {
|
||||||
|
t.Fatal("expected an error for a non remote-emby id")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,7 +159,18 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
|
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
|
||||||
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
// 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。
|
||||||
if b.c.EmbyRemote != nil {
|
if b.c.EmbyRemote != nil {
|
||||||
b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
b.c.Danmaku.SetRemoteMediaResolver(b.resolveDanmakuRemoteMedia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDanmakuRemoteMedia 把远程 Emby 伪 ID 解析成弹幕识别要用的 Media 与直链 URL。
|
||||||
|
//
|
||||||
|
// 目录类条目(整部剧/季/目录)自身没有媒体源,拿它的 ID 去请求远程
|
||||||
|
// /Videos/{id}/stream 只会回 500(Unable to cast object of type 'Series' to type
|
||||||
|
// 'IHasMediaSources'),哈希层于是每次都在这里白白失败、退回按文件名搜索。所以先按
|
||||||
|
// 与 /api/stream 相同的规则回落到第一集:算出来的哈希对得上真正播放的那条流,元数据
|
||||||
|
// (时长、文件名)也才是播放中那一集的。
|
||||||
|
func (b *serviceContainerBuilder) resolveDanmakuRemoteMedia(ctx context.Context, encodedID string) (*model.Media, string, error) {
|
||||||
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
|
mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, "", errors.New("invalid emby remote id")
|
return nil, "", errors.New("invalid emby remote id")
|
||||||
@@ -171,14 +182,16 @@ func (b *serviceContainerBuilder) initContentServices() {
|
|||||||
}
|
}
|
||||||
return nil, "", errors.New("emby mount or account not found")
|
return nil, "", errors.New("emby mount or account not found")
|
||||||
}
|
}
|
||||||
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID)
|
playable := b.c.EmbyRemote.PlayableRemoteID(ctx, acct, remoteID)
|
||||||
|
if strings.TrimSpace(playable) == "" {
|
||||||
|
playable = remoteID
|
||||||
|
}
|
||||||
|
m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, playable)
|
||||||
if err != nil || m == nil {
|
if err != nil || m == nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID)
|
streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, playable)
|
||||||
return m, streamURL, nil
|
return m, streamURL, nil
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
func (b *serviceContainerBuilder) initAccessAndStorageServices() {
|
||||||
|
|||||||
@@ -102,7 +102,10 @@ export const danmakuAPI = {
|
|||||||
api
|
api
|
||||||
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
|
.get<DanmakuFetchResult>(`/danmaku/${encodeURIComponent(mediaId)}`, {
|
||||||
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
|
params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined },
|
||||||
timeout: 20_000,
|
// 服务端依次做「16MB 前缀哈希 → 上游匹配 → 评论库」三段串行请求,每段的上游
|
||||||
|
// 超时都是 20 秒。客户端要是也卡在 20 秒,就会在服务端正要降级返回(哈希超时
|
||||||
|
// 后改按文件名搜索)时把请求撤掉:用户看不到弹幕,服务端还记一条 404。
|
||||||
|
timeout: 45_000,
|
||||||
})
|
})
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
|||||||
@@ -903,6 +903,13 @@ export function PlayerPage() {
|
|||||||
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
|
// 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mediaId) return
|
if (!mediaId) return
|
||||||
|
// 远程 Emby 条目只存在于远程库里,本地 media 表没有它的伪 ID,问这个接口只会
|
||||||
|
// 拿到 404(控制台里就是一条红字);而它的播放方式恒为「远程直连、不转码」,
|
||||||
|
// 也没有任何可切换的档位。播放地址由 /api/stream/:id 负责解析。
|
||||||
|
if (isRemoteEmbyID(mediaId)) {
|
||||||
|
setPlaybackInfo(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
mediaAPI
|
mediaAPI
|
||||||
.playbackInfo(mediaId)
|
.playbackInfo(mediaId)
|
||||||
|
|||||||
Reference in New Issue
Block a user