From 44fde6c5a0de1a961f2f3788bb0452f53fc8a9ad Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Wed, 23 Sep 2026 13:53:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/repository/media_repository.go | 33 ++++++ internal/service/introdb.go | 131 ++++++++++++++++++++-- internal/service/introdb_test.go | 142 ++++++++++++++++++++++++ internal/service/media_segment.go | 58 ++++++++-- internal/service/media_segment_test.go | 125 +++++++++++++++++++++ 5 files changed, 466 insertions(+), 23 deletions(-) diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index 22a6512..23c49e0 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -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}) diff --git a/internal/service/introdb.go b/internal/service/introdb.go index dd24923..1cfb4f4 100644 --- a/internal/service/introdb.go +++ b/internal/service/introdb.go @@ -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 { diff --git a/internal/service/introdb_test.go b/internal/service/introdb_test.go index 0f178ab..9333b49 100644 --- a/internal/service/introdb_test.go +++ b/internal/service/introdb_test.go @@ -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) + } + } +} diff --git a/internal/service/media_segment.go b/internal/service/media_segment.go index 42d6c4d..f4fecec 100644 --- a/internal/service/media_segment.go +++ b/internal/service/media_segment.go @@ -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 { diff --git a/internal/service/media_segment_test.go b/internal/service/media_segment_test.go index e0369c7..6d52984 100644 --- a/internal/service/media_segment_test.go +++ b/internal/service/media_segment_test.go @@ -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()