mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user