mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
402 lines
14 KiB
Go
402 lines
14 KiB
Go
package service
|
||
|
||
import (
|
||
"context"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
"go.uber.org/zap"
|
||
|
||
"github.com/truewhile/MeBox/internal/model"
|
||
"github.com/truewhile/MeBox/internal/repository"
|
||
)
|
||
|
||
func newSegmentServiceFixture(t *testing.T, handler http.HandlerFunc) (*MediaSegmentService, *repository.Container, *int32) {
|
||
t.Helper()
|
||
repos := repository.New(newServiceTestDB(t))
|
||
var calls int32
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
atomic.AddInt32(&calls, 1)
|
||
handler(w, r)
|
||
}))
|
||
t.Cleanup(server.Close)
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||
return svc, repos, &calls
|
||
}
|
||
|
||
func writeJSONBody(body string) http.HandlerFunc {
|
||
return func(w http.ResponseWriter, _ *http.Request) {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(body))
|
||
}
|
||
}
|
||
|
||
// 剧集必须用「剧集级」TMDb id 查询,而 Media.TMDbID 存的是单集自己的 id:
|
||
// 刮削写的是 episode 的 tmdb id(见 local_metadata_test.go 的约束)。
|
||
func TestQueryIDsUsesSeriesTMDbForEpisodes(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
ctx := t.Context()
|
||
|
||
if err := repos.DB.Create(&model.Series{
|
||
Base: model.Base{ID: "s-1"}, Title: "Breaking Bad", TMDbID: 1396,
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
episode := &model.Media{
|
||
Base: model.Base{ID: "ep-1"},
|
||
SeriesID: "s-1",
|
||
SeasonNum: 1,
|
||
EpisodeNum: 2,
|
||
TMDbID: 4375419, // 单集 id,不是剧集 id
|
||
}
|
||
tmdbID, season, episodeNum := svc.queryIDs(ctx, episode)
|
||
if tmdbID != 1396 {
|
||
t.Fatalf("tmdbID = %d, want the series id 1396 (not the episode id)", tmdbID)
|
||
}
|
||
if season != 1 || episodeNum != 2 {
|
||
t.Fatalf("season/episode = %d/%d, want 1/2", season, episodeNum)
|
||
}
|
||
}
|
||
|
||
func TestQueryIDsForMovieUsesOwnTMDb(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
|
||
tmdbID, season, episode := svc.queryIDs(t.Context(), &model.Media{
|
||
Base: model.Base{ID: "mv-1"}, TMDbID: 27205,
|
||
})
|
||
if tmdbID != 27205 || season != 0 || episode != 0 {
|
||
t.Fatalf("query = (%d,%d,%d), want (27205,0,0)", tmdbID, season, episode)
|
||
}
|
||
}
|
||
|
||
func TestQueryIDsIsNotResolvableBeforeScrape(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
ctx := t.Context()
|
||
|
||
// 剧集还没关联 Series:解析不出来,但也不能当成「查过且没有」。
|
||
if tmdbID, _, _ := svc.queryIDs(ctx, &model.Media{
|
||
Base: model.Base{ID: "ep-orphan"}, SeasonNum: 1, EpisodeNum: 1,
|
||
}); tmdbID != 0 {
|
||
t.Fatalf("tmdbID = %d, want 0", tmdbID)
|
||
}
|
||
// 没刮削过的电影同理。
|
||
if tmdbID, _, _ := svc.queryIDs(ctx, &model.Media{Base: model.Base{ID: "mv-noscrape"}}); tmdbID != 0 {
|
||
t.Fatalf("tmdbID = %d, want 0", tmdbID)
|
||
}
|
||
}
|
||
|
||
// 部分刮削路径(生产环境动漫库实测如此)不建 Series 行,而是把「剧集级」
|
||
// TMDb id 直接写在 Media.TMDbID 上:同一剧名下各集共用同一个 id。
|
||
// 原先这类行一律解析不出 id,整个动漫库等于查不到任何片段。
|
||
func TestQueryIDsFallsBackToMediaTMDbWhenSiblingsShareIt(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
ctx := t.Context()
|
||
|
||
episodes := []*model.Media{
|
||
{Base: model.Base{ID: "ep-6"}, LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||
{Base: model.Base{ID: "ep-7"}, LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||
}
|
||
for _, ep := range episodes {
|
||
if err := repos.DB.Create(ep).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
tmdbID, season, episode := svc.queryIDs(ctx, episodes[0])
|
||
if tmdbID != 61970 || season != 1 || episode != 6 {
|
||
t.Fatalf("query = (%d,%d,%d), want (61970,1,6): a shared id is a series id", tmdbID, season, episode)
|
||
}
|
||
}
|
||
|
||
// 反例(重要):另一些刮削路径把「单集自己的」id 写在 Media.TMDbID 上,
|
||
// 每集都不同。这种 id 不能当剧集 id 用——拿它去查会命中完全不相干的片子。
|
||
func TestQueryIDsRejectsPerEpisodeTMDbWithoutSiblings(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
ctx := t.Context()
|
||
|
||
episodes := []*model.Media{
|
||
{Base: model.Base{ID: "ep-1"}, LibraryID: "lib-tv", Title: "某剧", Path: "/tv/some/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 4_375_419},
|
||
{Base: model.Base{ID: "ep-2"}, LibraryID: "lib-tv", Title: "某剧", Path: "/tv/some/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 4_375_420},
|
||
}
|
||
for _, ep := range episodes {
|
||
if err := repos.DB.Create(ep).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
if tmdbID, _, _ := svc.queryIDs(ctx, episodes[0]); tmdbID != 0 {
|
||
t.Fatalf("tmdbID = %d, want 0: a per-episode id must not be used as a series id", tmdbID)
|
||
}
|
||
}
|
||
|
||
// Series 行存在时永远优先,哪怕 Media.TMDbID 看起来也像个共用 id。
|
||
func TestQueryIDsPrefersSeriesTMDbOverSharedMediaTMDb(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
ctx := t.Context()
|
||
|
||
if err := repos.DB.Create(&model.Series{
|
||
Base: model.Base{ID: "s-1"}, Title: "便·当", TMDbID: 1396,
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
episodes := []*model.Media{
|
||
{Base: model.Base{ID: "ep-a"}, SeriesID: "s-1", LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||
{Base: model.Base{ID: "ep-b"}, SeriesID: "s-1", LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||
}
|
||
for _, ep := range episodes {
|
||
if err := repos.DB.Create(ep).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
if tmdbID, _, _ := svc.queryIDs(ctx, episodes[0]); tmdbID != 1396 {
|
||
t.Fatalf("tmdbID = %d, want the Series id 1396", tmdbID)
|
||
}
|
||
}
|
||
|
||
// 关联了 Series 但那条 Series 没刮到 id 时,仍然走 Media.TMDbID 兜底。
|
||
func TestQueryIDsFallsBackWhenSeriesHasNoTMDb(t *testing.T) {
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||
ctx := t.Context()
|
||
|
||
if err := repos.DB.Create(&model.Series{
|
||
Base: model.Base{ID: "s-2"}, Title: "便·当", TMDbID: 0,
|
||
}).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
episodes := []*model.Media{
|
||
{Base: model.Base{ID: "ep-c"}, SeriesID: "s-2", Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||
{Base: model.Base{ID: "ep-d"}, SeriesID: "s-2", Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||
}
|
||
for _, ep := range episodes {
|
||
if err := repos.DB.Create(ep).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
if tmdbID, _, _ := svc.queryIDs(ctx, episodes[0]); tmdbID != 61970 {
|
||
t.Fatalf("tmdbID = %d, want the shared Media id 61970", tmdbID)
|
||
}
|
||
}
|
||
|
||
func TestListForPlaybackQueriesEpisodesWithSharedSeriesTMDb(t *testing.T) {
|
||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBTVPayload))
|
||
ctx := t.Context()
|
||
episodes := []*model.Media{
|
||
{Base: model.Base{ID: "ep-x"}, LibraryID: "lib-anime", Title: "便·当",
|
||
Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||
{Base: model.Base{ID: "ep-y"}, LibraryID: "lib-anime", Title: "便·当",
|
||
Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||
}
|
||
for _, ep := range episodes {
|
||
if err := repos.DB.Create(ep).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
rows, err := svc.ListForPlayback(ctx, episodes[0])
|
||
if err != nil {
|
||
t.Fatalf("call: %v", err)
|
||
}
|
||
if len(rows) != 2 {
|
||
t.Fatalf("rows = %#v, want the two spans the provider returned", rows)
|
||
}
|
||
if got := atomic.LoadInt32(calls); got != 1 {
|
||
t.Fatalf("provider calls = %d, want 1", got)
|
||
}
|
||
}
|
||
|
||
func TestListForPlaybackFetchesOnceThenServesCache(t *testing.T) {
|
||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||
ctx := t.Context()
|
||
m := &model.Media{Base: model.Base{ID: "mv-1"}, Path: "/movies/inception.mkv", TMDbID: 27205}
|
||
if err := repos.DB.Create(m).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
for i := 0; i < 3; i++ {
|
||
rows, err := svc.ListForPlayback(ctx, m)
|
||
if err != nil {
|
||
t.Fatalf("call #%d: %v", i+1, err)
|
||
}
|
||
if len(rows) != 1 {
|
||
t.Fatalf("call #%d rows = %#v, want 1", i+1, rows)
|
||
}
|
||
}
|
||
if got := atomic.LoadInt32(calls); got != 1 {
|
||
t.Fatalf("provider calls = %d, want 1 (later plays must hit the local cache)", got)
|
||
}
|
||
got, err := repos.MediaSegment.ListByMedia(ctx, "mv-1")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(got) != 1 || got[0].Kind != model.SegmentKindIntro || got[0].StartMs != 0 || got[0].EndMs != 38_000 {
|
||
t.Fatalf("persisted rows = %#v", got)
|
||
}
|
||
if got[0].Source != IntroDBSource {
|
||
t.Fatalf("source = %q, want %q", got[0].Source, IntroDBSource)
|
||
}
|
||
}
|
||
|
||
func TestListForPlaybackCachesMisses(t *testing.T) {
|
||
svc, repos, calls := newSegmentServiceFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||
w.WriteHeader(http.StatusNotFound)
|
||
})
|
||
ctx := t.Context()
|
||
m := &model.Media{Base: model.Base{ID: "mv-2"}, Path: "/movies/nobody-knows.mkv", TMDbID: 424242}
|
||
if err := repos.DB.Create(m).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
for i := 0; i < 2; i++ {
|
||
rows, err := svc.ListForPlayback(ctx, m)
|
||
if err != nil {
|
||
t.Fatalf("call #%d: %v", i+1, err)
|
||
}
|
||
if len(rows) != 0 {
|
||
t.Fatalf("call #%d rows = %#v, want none", i+1, rows)
|
||
}
|
||
}
|
||
// 负缓存是必需的:否则每次播放这部片都会重新打一次外网。
|
||
if got := atomic.LoadInt32(calls); got != 1 {
|
||
t.Fatalf("provider calls = %d, want 1 (a miss must be cached too)", got)
|
||
}
|
||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-2", IntroDBSource)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ledger == nil || ledger.Found {
|
||
t.Fatalf("ledger = %#v, want a recorded miss", ledger)
|
||
}
|
||
}
|
||
|
||
func TestListForPlaybackSkipsProviderWithoutExternalID(t *testing.T) {
|
||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||
ctx := t.Context()
|
||
m := &model.Media{Base: model.Base{ID: "mv-3"}, Path: "/movies/unscraped.mkv"}
|
||
if err := repos.DB.Create(m).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if _, err := svc.ListForPlayback(ctx, m); err != nil {
|
||
t.Fatalf("call: %v", err)
|
||
}
|
||
if got := atomic.LoadInt32(calls); got != 0 {
|
||
t.Fatalf("provider calls = %d, want 0 without a tmdb id", got)
|
||
}
|
||
// 关键:解析不出外部 ID 时不能写负缓存,否则刮削完成后就永远不会再查了。
|
||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-3", IntroDBSource)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ledger != nil {
|
||
t.Fatalf("ledger = %#v, want none while metadata is still missing", ledger)
|
||
}
|
||
}
|
||
|
||
func TestListForPlaybackKeepsCacheWhenProviderFails(t *testing.T) {
|
||
var calls int32
|
||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||
if atomic.AddInt32(&calls, 1) == 1 {
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||
return
|
||
}
|
||
w.WriteHeader(http.StatusInternalServerError)
|
||
}))
|
||
defer server.Close()
|
||
|
||
repos := repository.New(newServiceTestDB(t))
|
||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||
ctx := t.Context()
|
||
m := &model.Media{Base: model.Base{ID: "mv-4"}, Path: "/movies/flaky.mkv", TMDbID: 27205}
|
||
if err := repos.DB.Create(m).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
if rows, err := svc.ListForPlayback(ctx, m); err != nil || len(rows) != 1 {
|
||
t.Fatalf("first call rows=%#v err=%v", rows, err)
|
||
}
|
||
// 让缓存过期,制造一次会失败的刷新。
|
||
if err := repos.DB.Model(&model.MediaSegmentFetch{}).
|
||
Where("media_id = ?", "mv-4").
|
||
Update("fetched_at", time.Now().Add(-segmentFoundTTL-time.Hour)).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rows, err := svc.ListForPlayback(ctx, m)
|
||
if err != nil {
|
||
t.Fatalf("provider failure must not surface as an error: %v", err)
|
||
}
|
||
if len(rows) != 1 {
|
||
t.Fatalf("rows = %#v, want the previous cache kept", rows)
|
||
}
|
||
}
|
||
|
||
func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
|
||
// 第三方客户端(Emby)会在起播路径上同步请求片段,它给的超时必须生效,
|
||
// 不能被一次外网抓取拖住;同时超时不能变成「负缓存」,否则就再也补不上了。
|
||
svc, repos, calls := newSegmentServiceFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||
time.Sleep(400 * time.Millisecond)
|
||
w.Header().Set("Content-Type", "application/json")
|
||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||
})
|
||
ctx := t.Context()
|
||
m := &model.Media{Base: model.Base{ID: "mv-5"}, Path: "/movies/budget.mkv", TMDbID: 27205}
|
||
if err := repos.DB.Create(m).Error; err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
budgeted, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||
defer cancel()
|
||
rows, err := svc.ListForPlayback(budgeted, m)
|
||
if err != nil {
|
||
t.Fatalf("an exhausted fetch budget must not surface as an error: %v", err)
|
||
}
|
||
if len(rows) != 0 {
|
||
t.Fatalf("rows = %#v, want none when the caller's budget ran out", rows)
|
||
}
|
||
if got := atomic.LoadInt32(calls); got != 1 {
|
||
t.Fatalf("provider calls = %d, want 1 (the attempt was made then abandoned)", got)
|
||
}
|
||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-5", IntroDBSource)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ledger != nil {
|
||
t.Fatal("a timed-out fetch must not be recorded as a negative cache entry")
|
||
}
|
||
|
||
// 预算正常时(下一次播放)仍然能补上。
|
||
rows, err = svc.ListForPlayback(ctx, m)
|
||
if err != nil || len(rows) != 1 {
|
||
t.Fatalf("second call rows=%#v err=%v, want the fetched segment", rows, err)
|
||
}
|
||
}
|
||
|
||
func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||
now := time.Now()
|
||
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true}
|
||
if !ledgerFresh(found) {
|
||
t.Fatal("a hit should still be fresh just past the miss TTL")
|
||
}
|
||
miss := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: false}
|
||
if ledgerFresh(miss) {
|
||
t.Fatal("a miss should expire after the miss TTL")
|
||
}
|
||
if ledgerFresh(nil) {
|
||
t.Fatal("a missing ledger must not be considered fresh")
|
||
}
|
||
}
|