mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-14 17:16:37 +08:00
161 lines
5.7 KiB
Go
161 lines
5.7 KiB
Go
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)
|
||
}
|
||
}
|