mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
218 lines
7.8 KiB
Go
218 lines
7.8 KiB
Go
package handler
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync/atomic"
|
||
"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"
|
||
)
|
||
|
||
type embySegmentsPayload struct {
|
||
Items []struct {
|
||
ID string `json:"Id"`
|
||
ItemID string `json:"ItemId"`
|
||
Type string `json:"Type"`
|
||
StartTicks int64 `json:"StartTicks"`
|
||
EndTicks int64 `json:"EndTicks"`
|
||
} `json:"Items"`
|
||
TotalRecordCount int `json:"TotalRecordCount"`
|
||
}
|
||
|
||
// 电影:intro 有明确结束点;credits 的 end_ms 为 null(库内落成 0),
|
||
// 必须用媒体时长补齐 —— 这是最容易写错的一处。
|
||
const embySegmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
|
||
|
||
const (
|
||
embySegmentsMovieDurationSec = 8880
|
||
embySegmentsTicksPerSecond = 10_000_000
|
||
)
|
||
|
||
func newEmbySegmentsTestRouter(t *testing.T, durationSec int) (*gin.Engine, string, *int32) {
|
||
t.Helper()
|
||
gin.SetMode(gin.TestMode)
|
||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.AutoMigrate(model.AllModels()...); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
repos := repository.New(db)
|
||
if err := repos.User.Create(t.Context(), &model.User{
|
||
Base: model.Base{ID: "user-1"},
|
||
Username: "tester",
|
||
PasswordHash: "x",
|
||
Role: "admin",
|
||
Tier: "plus",
|
||
IsActive: true,
|
||
}); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}
|
||
if err := repos.Library.Create(t.Context(), &lib); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := db.Create(&model.Media{
|
||
Base: model.Base{ID: "movie-1"},
|
||
LibraryID: lib.ID,
|
||
Title: "Inception",
|
||
Path: "D:\\media\\movies\\Inception.mkv",
|
||
DurationSec: durationSec,
|
||
TMDbID: 27205,
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
var calls int32
|
||
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
atomic.AddInt32(&calls, 1)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(embySegmentsProviderBody))
|
||
}))
|
||
t.Cleanup(provider.Close)
|
||
|
||
segments := service.NewMediaSegmentService(zap.NewNop(), repos).
|
||
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
|
||
|
||
const secret = "test-secret"
|
||
router := gin.New()
|
||
registerEmbyRoutes(router, secret, &service.Container{
|
||
Repo: repos,
|
||
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
|
||
Segments: segments,
|
||
Log: zap.NewNop(),
|
||
})
|
||
return router, secret, &calls
|
||
}
|
||
|
||
func embySegmentsRequest(t *testing.T, router *gin.Engine, secret, path string) embySegmentsPayload {
|
||
t.Helper()
|
||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
|
||
w := httptest.NewRecorder()
|
||
router.ServeHTTP(w, req)
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
|
||
}
|
||
var payload embySegmentsPayload
|
||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||
t.Fatalf("%s decode: %v", path, err)
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func TestEmbyMediaSegmentsReturnsTicksAndMapsCreditsToOutro(t *testing.T) {
|
||
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||
|
||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
|
||
if payload.TotalRecordCount != 2 || len(payload.Items) != 2 {
|
||
t.Fatalf("segments = %#v (total %d), want 2", payload.Items, payload.TotalRecordCount)
|
||
}
|
||
|
||
intro := payload.Items[0]
|
||
if intro.Type != "Intro" || intro.ItemID != "movie-1" || intro.ID == "" {
|
||
t.Fatalf("intro segment = %#v", intro)
|
||
}
|
||
// start_ms null 表示从片头开始。
|
||
if intro.StartTicks != 0 || intro.EndTicks != 38*embySegmentsTicksPerSecond {
|
||
t.Fatalf("intro ticks = %d..%d, want 0..%d",
|
||
intro.StartTicks, intro.EndTicks, 38*embySegmentsTicksPerSecond)
|
||
}
|
||
|
||
// 库内 credits 在 Emby 一侧是 Outro;end_ms = 0 必须按媒体时长补齐,
|
||
// 否则客户端会拿到一个零长度区间。
|
||
outro := payload.Items[1]
|
||
if outro.Type != "Outro" {
|
||
t.Fatalf("credits should map to Outro, got %q", outro.Type)
|
||
}
|
||
if outro.StartTicks != 6480*embySegmentsTicksPerSecond {
|
||
t.Fatalf("outro StartTicks = %d, want %d", outro.StartTicks, 6480*embySegmentsTicksPerSecond)
|
||
}
|
||
if outro.EndTicks != embySegmentsMovieDurationSec*embySegmentsTicksPerSecond {
|
||
t.Fatalf("outro EndTicks = %d, want the media duration %d",
|
||
outro.EndTicks, embySegmentsMovieDurationSec*embySegmentsTicksPerSecond)
|
||
}
|
||
}
|
||
|
||
func TestEmbyMediaSegmentsIsServedFromTheSameCacheAsTheWebPlayer(t *testing.T) {
|
||
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||
|
||
// 多条路径 + 大小写变体都应命中同一份缓存,而不是各自再打一次外网。
|
||
for _, path := range []string{
|
||
"/MediaSegments/movie-1",
|
||
"/mediasegments/movie-1",
|
||
"/Items/movie-1/MediaSegments",
|
||
"/items/movie-1/mediasegments",
|
||
"/Users/user-1/Items/movie-1/MediaSegments",
|
||
} {
|
||
payload := embySegmentsRequest(t, router, secret, path)
|
||
if len(payload.Items) != 2 {
|
||
t.Fatalf("%s returned %#v, want 2 segments", path, payload.Items)
|
||
}
|
||
}
|
||
if got := atomic.LoadInt32(calls); got != 1 {
|
||
t.Fatalf("provider calls = %d, want 1 (all routes share the cached rows)", got)
|
||
}
|
||
}
|
||
|
||
func TestEmbyMediaSegmentsHonoursIncludeSegmentTypes(t *testing.T) {
|
||
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||
|
||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=Intro")
|
||
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
|
||
t.Fatalf("filtered segments = %#v (total %d), want only Intro", payload.Items, payload.TotalRecordCount)
|
||
}
|
||
if payload.Items[0].Type != "Intro" {
|
||
t.Fatalf("filtered type = %q, want Intro", payload.Items[0].Type)
|
||
}
|
||
|
||
// 认不出的枚举名按「不过滤」处理:返回超集比返回空集安全。
|
||
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=NotAType")
|
||
if payload.TotalRecordCount != 2 {
|
||
t.Fatalf("unknown filter returned %d segments, want the unfiltered set", payload.TotalRecordCount)
|
||
}
|
||
}
|
||
|
||
func TestEmbyMediaSegmentsDropsOpenEndedRangeWhenDurationUnknown(t *testing.T) {
|
||
// 时长未知(STRM/云盘媒体探测前)时,credits 无法换算成真实结束点,
|
||
// 只能丢弃;有明确结束点的 intro 必须保留。
|
||
router, secret, _ := newEmbySegmentsTestRouter(t, 0)
|
||
|
||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
|
||
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
|
||
t.Fatalf("segments = %#v (total %d), want only the intro", payload.Items, payload.TotalRecordCount)
|
||
}
|
||
if payload.Items[0].Type != "Intro" {
|
||
t.Fatalf("kept segment = %#v, want Intro", payload.Items[0])
|
||
}
|
||
}
|
||
|
||
func TestEmbyMediaSegmentsReturnsEmptyInsteadOfNotFound(t *testing.T) {
|
||
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
|
||
|
||
// 未知条目必须 200 + 空数组:客户端会把 404 判成「条目损坏」。
|
||
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/does-not-exist")
|
||
if payload.Items == nil || len(payload.Items) != 0 || payload.TotalRecordCount != 0 {
|
||
t.Fatalf("unknown item payload = %#v", payload)
|
||
}
|
||
// 远程 Emby 条目同理(本地没有可查询的外部 ID 关联)。
|
||
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/embyremote~acct1~item1")
|
||
if len(payload.Items) != 0 {
|
||
t.Fatalf("remote emby item payload = %#v, want empty", payload.Items)
|
||
}
|
||
if got := atomic.LoadInt32(calls); got != 0 {
|
||
t.Fatalf("provider calls = %d, want 0 for unresolvable items", got)
|
||
}
|
||
}
|