diff --git a/internal/service/danmaku_remote_resolver_test.go b/internal/service/danmaku_remote_resolver_test.go new file mode 100644 index 0000000..28f1cad --- /dev/null +++ b/internal/service/danmaku_remote_resolver_test.go @@ -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") + } +} diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index ec64b6f..498bb3d 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -159,28 +159,41 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA) // 弹幕识别需要把远程 Emby 条目解析为 Media 元数据及可拉取前 16MB 的直链 URL。 if b.c.EmbyRemote != nil { - b.c.Danmaku.SetRemoteMediaResolver(func(ctx context.Context, encodedID string) (*model.Media, string, error) { - mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID) - if !ok { - return nil, "", errors.New("invalid emby remote id") - } - mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID) - if err != nil || mount == nil || acct == nil { - if err != nil { - return nil, "", err - } - return nil, "", errors.New("emby mount or account not found") - } - m, err := b.c.EmbyRemote.RemoteMediaDetail(ctx, mount, acct, remoteID) - if err != nil || m == nil { - return nil, "", err - } - streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, remoteID) - return m, streamURL, nil - }) + 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) + if !ok { + return nil, "", errors.New("invalid emby remote id") + } + mount, acct, err := b.c.EmbyRemote.ResolveMount(ctx, mountID) + if err != nil || mount == nil || acct == nil { + if err != nil { + return nil, "", err + } + return nil, "", errors.New("emby mount or account not found") + } + 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 { + return nil, "", err + } + streamURL, _ := b.c.EmbyRemote.WebStreamURL(ctx, acct, playable) + return m, streamURL, nil +} + func (b *serviceContainerBuilder) initAccessAndStorageServices() { b.c.PlayProfiles = NewPlayProfileService(b.log, b.repos) b.c.Permissions = NewPermissionService(b.log, b.repos) diff --git a/web/src/api/danmaku.ts b/web/src/api/danmaku.ts index c13d58f..ba66163 100644 --- a/web/src/api/danmaku.ts +++ b/web/src/api/danmaku.ts @@ -102,7 +102,10 @@ export const danmakuAPI = { api .get(`/danmaku/${encodeURIComponent(mediaId)}`, { params: { kw: options.kw || undefined, episodeId: options.episodeId || undefined }, - timeout: 20_000, + // 服务端依次做「16MB 前缀哈希 → 上游匹配 → 评论库」三段串行请求,每段的上游 + // 超时都是 20 秒。客户端要是也卡在 20 秒,就会在服务端正要降级返回(哈希超时 + // 后改按文件名搜索)时把请求撤掉:用户看不到弹幕,服务端还记一条 404。 + timeout: 45_000, }) .then((r) => r.data), diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index f67c762..c3650b2 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -903,6 +903,13 @@ export function PlayerPage() { // 加载统一播放能力:115 云端清晰度 + 本地 HLS 清晰度。 useEffect(() => { if (!mediaId) return + // 远程 Emby 条目只存在于远程库里,本地 media 表没有它的伪 ID,问这个接口只会 + // 拿到 404(控制台里就是一条红字);而它的播放方式恒为「远程直连、不转码」, + // 也没有任何可切换的档位。播放地址由 /api/stream/:id 负责解析。 + if (isRemoteEmbyID(mediaId)) { + setPlaybackInfo(null) + return + } let cancelled = false mediaAPI .playbackInfo(mediaId)