This commit is contained in:
truewhile
2026-09-23 13:53:50 +08:00
parent 94ef0116b1
commit 44fde6c5a0
5 changed files with 466 additions and 23 deletions
+33
View File
@@ -199,6 +199,39 @@ func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media
return &m, nil
}
// ExistsSiblingWithTMDbID reports whether another row of the same show carries
// the same tm_db_id as m.
//
// 它的用途是把「剧集级 id」和「单集自己的 id」区分开:一部剧的多集共用一个
// 剧集级 id,而单集各自的 id 不会重复。调用方据此决定能否把 Media.TMDbID
// 当作 Series.TMDbID 的替代品(见 MediaSegmentService.queryIDs)。
//
// 同一部剧的判定优先用 series_id;没有 series_id 的行(部分刮削路径不写它)
// 退回到「同一个库 + 同一个标题」。查询失败按「不共用」处理:宁可不查,
// 也不能拿一个可能是单集的 id 去查错片。
func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.Media) bool {
if r == nil || m == nil || m.TMDbID <= 0 || m.ID == "" {
return false
}
query := r.db.WithContext(ctx).Model(&model.Media{}).
Where("tm_db_id = ? AND id <> ?", m.TMDbID, m.ID)
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
query = query.Where("series_id = ?", seriesID)
} else {
libraryID := strings.TrimSpace(m.LibraryID)
title := strings.TrimSpace(m.Title)
if libraryID == "" || title == "" {
return false
}
query = query.Where("library_id = ? AND title = ?", libraryID, title)
}
var count int64
if err := query.Limit(1).Count(&count).Error; err != nil {
return false
}
return count > 0
}
// ListByLibrary returns paginated media items for a library.
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
+119 -12
View File
@@ -29,6 +29,15 @@ const (
introDBTimeout = 8 * time.Second
introDBMaxBodySize = 1 << 20
// introDBMaxAttempts 是一次 Fetch 允许的请求次数(原请求 + 1 次重试)。
// 实测:短时间连发 45 个请求有 15 个被返回 429,加 1.2 秒间隔重试后
// 其中 10 个成功,所以限流是真实存在的、值得一次重试。
introDBMaxAttempts = 2
// introDBRetryDelay 是服务端没给 Retry-After 时的默认重试间隔。
introDBRetryDelay = time.Second
// introDBMaxRetryDelay 限制服务端要求的等待时间:一次播放不值得为它
// 挂住几十秒,等待超过这个值就按这个值等(然后可能再次被限流)。
introDBMaxRetryDelay = 3 * time.Second
)
// IntroDBSpan is one resolved skip range, still in provider terms.
@@ -42,18 +51,20 @@ type IntroDBSpan struct {
// IntroDBService queries TheIntroDB for one media item.
type IntroDBService struct {
log *zap.Logger
client *http.Client
baseURL string
log *zap.Logger
client *http.Client
baseURL string
retryDelay time.Duration
}
// NewIntroDBService is the constructor. The client honours environment and OS
// proxy settings so it behaves like the other third-party API clients.
func NewIntroDBService(log *zap.Logger) *IntroDBService {
return &IntroDBService{
log: log,
client: NewExternalHTTPClient(introDBTimeout),
baseURL: IntroDBBaseURL,
log: log,
client: NewExternalHTTPClient(introDBTimeout),
baseURL: IntroDBBaseURL,
retryDelay: introDBRetryDelay,
}
}
@@ -65,6 +76,15 @@ func (s *IntroDBService) SetBaseURL(base string) *IntroDBService {
return s
}
// SetRetryDelay overrides the wait between attempts. Tests set it to 0 so a
// retry does not really sleep.
func (s *IntroDBService) SetRetryDelay(delay time.Duration) *IntroDBService {
if s != nil {
s.retryDelay = delay
}
return s
}
// introDBRange mirrors one entry of a segment array. start_ms/end_ms are
// pointers because the API distinguishes null (= open-ended) from 0.
type introDBRange struct {
@@ -86,6 +106,10 @@ type introDBResponse struct {
// caller records it as a negative cache entry.
//
// season/episode are required for TV; pass 0/0 for movies.
//
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
if s == nil || s.client == nil {
return nil, errors.New("introdb service nil")
@@ -94,28 +118,111 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
return nil, nil
}
endpoint := s.mediaURL(tmdbID, season, episode)
for attempt := 1; ; attempt++ {
result := s.fetchOnce(ctx, endpoint)
if result.err == nil {
return result.spans, nil
}
if !result.retryable || attempt >= introDBMaxAttempts {
return nil, result.err
}
if !waitForIntroDBRetry(ctx, s.retryWait(result.retryAfter)) {
return nil, result.err
}
}
}
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
type introDBFetchAttempt struct {
spans []IntroDBSpan
err error
retryable bool
retryAfter time.Duration
}
func (s *IntroDBService) fetchOnce(ctx context.Context, endpoint string) introDBFetchAttempt {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
return introDBFetchAttempt{err: err}
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return nil, err
return introDBFetchAttempt{err: err}
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode == http.StatusNotFound:
return nil, nil
// 「查到但社区库里没有」不是错误,调用方据此写负缓存。
return introDBFetchAttempt{}
case resp.StatusCode < 200 || resp.StatusCode >= 300:
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
return introDBFetchAttempt{
err: fmt.Errorf("introdb: unexpected status %d", resp.StatusCode),
retryable: introDBRetryableStatus(resp.StatusCode),
retryAfter: parseIntroDBRetryAfter(resp.Header.Get("Retry-After")),
}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBMaxBodySize))
if err != nil {
return nil, err
return introDBFetchAttempt{err: err}
}
spans, err := parseIntroDBResponse(body)
if err != nil {
return introDBFetchAttempt{err: err}
}
return introDBFetchAttempt{spans: spans}
}
// introDBRetryableStatus 只认明确的「稍后再来」状态。500 之类的服务端故障
// 重试也不会变好,却会白占调用方的等待预算。
func introDBRetryableStatus(status int) bool {
switch status {
case http.StatusTooManyRequests, http.StatusServiceUnavailable:
return true
default:
return false
}
}
// parseIntroDBRetryAfter 解析 Retry-After 的秒数形式;HTTP-date 形式在限流
// 场景很少见,解析不出来就退回默认间隔。
func parseIntroDBRetryAfter(value string) time.Duration {
seconds, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || seconds <= 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
func (s *IntroDBService) retryWait(retryAfter time.Duration) time.Duration {
wait := retryAfter
if wait <= 0 {
wait = s.retryDelay
}
if wait > introDBMaxRetryDelay {
wait = introDBMaxRetryDelay
}
return wait
}
// waitForIntroDBRetry 睡到重试时刻,或调用方的 ctx 先结束。返回 false 表示
// 预算已经用完,调用方不该再等。
func waitForIntroDBRetry(ctx context.Context, wait time.Duration) bool {
if ctx.Err() != nil {
return false
}
if wait <= 0 {
return true
}
timer := time.NewTimer(wait)
defer timer.Stop()
select {
case <-timer.C:
return true
case <-ctx.Done():
return false
}
return parseIntroDBResponse(body)
}
func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
+142
View File
@@ -1,9 +1,12 @@
package service
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
@@ -160,3 +163,142 @@ func TestIntroDBFetchParsesBody(t *testing.T) {
t.Fatalf("spans = %#v", spans)
}
}
// 实测:从生产机连发 45 个请求有 15 个被返回 429,加间隔重试后其中 10 个成功。
// 所以限流值得一次重试,否则那部分播放会静默少掉「跳过片头」按钮。
func TestIntroDBFetchRetriesRateLimit(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if atomic.AddInt32(&calls, 1) == 1 {
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(introDBTVPayload))
}))
defer server.Close()
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)
spans, err := svc.Fetch(t.Context(), 1396, 1, 1)
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(spans) != 2 {
t.Fatalf("spans = %#v, want the retried response", spans)
}
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("calls = %d, want 2 (the original plus one retry)", got)
}
}
func TestIntroDBFetchGivesUpAfterRetryBudget(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusTooManyRequests)
}))
defer server.Close()
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)
if _, err := svc.Fetch(t.Context(), 1396, 1, 1); err == nil {
t.Fatal("a persistent 429 must surface as an error so the caller keeps its cache")
}
// 只重试一次:限流通常不是靠密集重试解决的,而调用方的等待预算有限。
if got := atomic.LoadInt32(&calls); got != 2 {
t.Fatalf("calls = %d, want 2 (one retry, then give up)", got)
}
}
func TestIntroDBFetchDoesNotRetryNotFound(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusNotFound)
}))
defer server.Close()
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)
spans, err := svc.Fetch(t.Context(), 424242, 0, 0)
if err != nil || len(spans) != 0 {
t.Fatalf("spans = %#v err = %v, want a cached miss", spans, err)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("calls = %d, want 1: 404 means \"no data\", it is not worth retrying", got)
}
}
// 调用方预算不够时不能为了重试干等:Emby 只给 5 秒,等下去会把
// 「少一个跳过按钮」升级成「请求超时」。
func TestIntroDBFetchSkipsRetryWhenCallerBudgetIsSpent(t *testing.T) {
var calls int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.WriteHeader(http.StatusTooManyRequests)
}))
defer server.Close()
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(time.Minute)
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancel()
started := time.Now()
if _, err := svc.Fetch(ctx, 1396, 1, 1); err == nil {
t.Fatal("want an error when the budget is spent")
}
if elapsed := time.Since(started); elapsed > 2*time.Second {
t.Fatalf("Fetch waited %s, want it to give up promptly", elapsed)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("calls = %d, want 1 without a retry", got)
}
}
func TestIntroDBRetryWaitPrefersRetryAfterHeader(t *testing.T) {
svc := NewIntroDBService(zap.NewNop())
if got := svc.retryWait(2 * time.Second); got != 2*time.Second {
t.Fatalf("retryWait(2s) = %s, want the server's value", got)
}
if got := svc.retryWait(0); got != introDBRetryDelay {
t.Fatalf("retryWait(0) = %s, want the default %s", got, introDBRetryDelay)
}
// 服务端可以要求等很久,但一次播放不值得为它挂住几十秒。
if got := svc.retryWait(10 * time.Minute); got != introDBMaxRetryDelay {
t.Fatalf("retryWait(10m) = %s, want it capped at %s", got, introDBMaxRetryDelay)
}
}
func TestParseIntroDBRetryAfter(t *testing.T) {
cases := []struct {
value string
want time.Duration
}{
{"2", 2 * time.Second},
{" 3 ", 3 * time.Second},
{"", 0},
{"abc", 0},
{"-5", 0},
{"0", 0},
}
for _, tc := range cases {
if got := parseIntroDBRetryAfter(tc.value); got != tc.want {
t.Fatalf("parseIntroDBRetryAfter(%q) = %s, want %s", tc.value, got, tc.want)
}
}
}
func TestIntroDBRetryableStatus(t *testing.T) {
retryable := []int{http.StatusTooManyRequests, http.StatusServiceUnavailable}
for _, status := range retryable {
if !introDBRetryableStatus(status) {
t.Fatalf("status %d should be retryable", status)
}
}
// 404 由 fetchOnce 单独处理;500 之类的服务端故障重试也不会变好,
// 却会白占调用方的等待预算。
notRetryable := []int{http.StatusNotFound, http.StatusInternalServerError, http.StatusBadRequest}
for _, status := range notRetryable {
if introDBRetryableStatus(status) {
t.Fatalf("status %d should not be retryable", status)
}
}
}
+47 -11
View File
@@ -6,6 +6,7 @@ package service
import (
"context"
"strings"
"time"
"go.uber.org/zap"
@@ -151,19 +152,19 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
return rows, true, nil
}
// queryIDs resolves the provider query key. Movies use their own TMDb id;
// episodes need the *series* TMDb id plus season/episode, because scraping
// stores the episode-level TMDb id on Media.TMDbID.
// queryIDs resolves the provider query key. Movies use their own TMDb id.
//
// 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径
// 不建 Series 行,而是把剧集级 id 直接写在 Media.TMDbID 上(生产环境动漫库
// 实测如此:同一剧名下各集共用同一个 id,52/52 个剧名都唯一)。这类行原先
// 一律解析不出 id,等于整库查不到任何片段,所以这里补一条兜底。
//
// 兜底必须验证「是不是剧集级 id」:Media.TMDbID 在另一些刮削路径下存的是
// 单集自己的 id,拿它去查会命中别的片子。判据是多集共用(见
// MediaRepository.ExistsSiblingWithTMDbID)——单集 id 不会在兄弟集上重复。
func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
if m.SeriesID == "" || m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
return 0, 0, 0
}
series, err := s.repo.Series.FindByID(ctx, m.SeriesID)
if err != nil || series == nil || series.TMDbID <= 0 {
return 0, 0, 0
}
return series.TMDbID, m.SeasonNum, m.EpisodeNum
return s.episodeQueryIDs(ctx, m)
}
if m.TMDbID > 0 {
return m.TMDbID, 0, 0
@@ -171,6 +172,41 @@ func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmd
return 0, 0, 0
}
func (s *MediaSegmentService) episodeQueryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
if m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
return 0, 0, 0
}
if seriesTMDbID := s.seriesTMDbID(ctx, m); seriesTMDbID > 0 {
return seriesTMDbID, m.SeasonNum, m.EpisodeNum
}
if !s.mediaTMDbIDLooksLikeSeries(ctx, m) {
return 0, 0, 0
}
return m.TMDbID, m.SeasonNum, m.EpisodeNum
}
// seriesTMDbID returns the series-level TMDb id, or 0 when the row has no
// Series association or that Series was never matched.
func (s *MediaSegmentService) seriesTMDbID(ctx context.Context, m *model.Media) int {
if strings.TrimSpace(m.SeriesID) == "" {
return 0
}
series, err := s.repo.Series.FindByID(ctx, m.SeriesID)
if err != nil || series == nil || series.TMDbID <= 0 {
return 0
}
return series.TMDbID
}
// mediaTMDbIDLooksLikeSeries reports whether Media.TMDbID can stand in for the
// series id: only an id shared by other episodes of the same show qualifies.
func (s *MediaSegmentService) mediaTMDbIDLooksLikeSeries(ctx context.Context, m *model.Media) bool {
if s == nil || s.repo == nil || m == nil || m.TMDbID <= 0 {
return false
}
return s.repo.Media.ExistsSiblingWithTMDbID(ctx, m)
}
// ledgerFresh reports whether a previous lookup is still within its TTL.
func ledgerFresh(row *model.MediaSegmentFetch) bool {
if row == nil {
+125
View File
@@ -92,6 +92,131 @@ func TestQueryIDsIsNotResolvableBeforeScrape(t *testing.T) {
}
}
// 部分刮削路径(生产环境动漫库实测如此)不建 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()