Files
MeBox/internal/service/emby_remote_playable_id_test.go
T
2026-10-12 23:43:40 +08:00

161 lines
5.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}