mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 21:36:37 +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
|
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.
|
// ListByLibrary returns paginated media items for a library.
|
||||||
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
|
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})
|
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
|
||||||
|
|||||||
+119
-12
@@ -29,6 +29,15 @@ const (
|
|||||||
|
|
||||||
introDBTimeout = 8 * time.Second
|
introDBTimeout = 8 * time.Second
|
||||||
introDBMaxBodySize = 1 << 20
|
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.
|
// IntroDBSpan is one resolved skip range, still in provider terms.
|
||||||
@@ -42,18 +51,20 @@ type IntroDBSpan struct {
|
|||||||
|
|
||||||
// IntroDBService queries TheIntroDB for one media item.
|
// IntroDBService queries TheIntroDB for one media item.
|
||||||
type IntroDBService struct {
|
type IntroDBService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
client *http.Client
|
client *http.Client
|
||||||
baseURL string
|
baseURL string
|
||||||
|
retryDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewIntroDBService is the constructor. The client honours environment and OS
|
// NewIntroDBService is the constructor. The client honours environment and OS
|
||||||
// proxy settings so it behaves like the other third-party API clients.
|
// proxy settings so it behaves like the other third-party API clients.
|
||||||
func NewIntroDBService(log *zap.Logger) *IntroDBService {
|
func NewIntroDBService(log *zap.Logger) *IntroDBService {
|
||||||
return &IntroDBService{
|
return &IntroDBService{
|
||||||
log: log,
|
log: log,
|
||||||
client: NewExternalHTTPClient(introDBTimeout),
|
client: NewExternalHTTPClient(introDBTimeout),
|
||||||
baseURL: IntroDBBaseURL,
|
baseURL: IntroDBBaseURL,
|
||||||
|
retryDelay: introDBRetryDelay,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +76,15 @@ func (s *IntroDBService) SetBaseURL(base string) *IntroDBService {
|
|||||||
return s
|
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
|
// introDBRange mirrors one entry of a segment array. start_ms/end_ms are
|
||||||
// pointers because the API distinguishes null (= open-ended) from 0.
|
// pointers because the API distinguishes null (= open-ended) from 0.
|
||||||
type introDBRange struct {
|
type introDBRange struct {
|
||||||
@@ -86,6 +106,10 @@ type introDBResponse struct {
|
|||||||
// caller records it as a negative cache entry.
|
// caller records it as a negative cache entry.
|
||||||
//
|
//
|
||||||
// season/episode are required for TV; pass 0/0 for movies.
|
// 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) {
|
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
|
||||||
if s == nil || s.client == nil {
|
if s == nil || s.client == nil {
|
||||||
return nil, errors.New("introdb service 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
|
return nil, nil
|
||||||
}
|
}
|
||||||
endpoint := s.mediaURL(tmdbID, season, episode)
|
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)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return introDBFetchAttempt{err: err}
|
||||||
}
|
}
|
||||||
req.Header.Set("Accept", "application/json")
|
req.Header.Set("Accept", "application/json")
|
||||||
resp, err := s.client.Do(req)
|
resp, err := s.client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return introDBFetchAttempt{err: err}
|
||||||
}
|
}
|
||||||
defer func() { _ = resp.Body.Close() }()
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case resp.StatusCode == http.StatusNotFound:
|
case resp.StatusCode == http.StatusNotFound:
|
||||||
return nil, nil
|
// 「查到但社区库里没有」不是错误,调用方据此写负缓存。
|
||||||
|
return introDBFetchAttempt{}
|
||||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
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))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBMaxBodySize))
|
||||||
if err != nil {
|
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 {
|
func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
@@ -160,3 +163,142 @@ func TestIntroDBFetchParsesBody(t *testing.T) {
|
|||||||
t.Fatalf("spans = %#v", spans)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -151,19 +152,19 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
|||||||
return rows, true, nil
|
return rows, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// queryIDs resolves the provider query key. Movies use their own TMDb id;
|
// 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.
|
// 剧集需要「剧集级」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) {
|
func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
|
||||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||||
if m.SeriesID == "" || m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
|
return s.episodeQueryIDs(ctx, m)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
if m.TMDbID > 0 {
|
if m.TMDbID > 0 {
|
||||||
return m.TMDbID, 0, 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
|
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.
|
// ledgerFresh reports whether a previous lookup is still within its TTL.
|
||||||
func ledgerFresh(row *model.MediaSegmentFetch) bool {
|
func ledgerFresh(row *model.MediaSegmentFetch) bool {
|
||||||
if row == nil {
|
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) {
|
func TestListForPlaybackFetchesOnceThenServesCache(t *testing.T) {
|
||||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||||
ctx := t.Context()
|
ctx := t.Context()
|
||||||
|
|||||||
Reference in New Issue
Block a user