Files
truewhile 44fde6c5a0 优化
2026-09-23 13:53:50 +08:00

292 lines
8.9 KiB
Go

// Package service — TheIntroDB client.
//
// TheIntroDB (https://theintrodb.org) is a community database of "skip"
// timestamps: intro, recap, end credits and previews. Reads are public and
// need no API key, which is what makes it usable as an automatic filler for
// the player's 跳过片头/片尾 feature.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"go.uber.org/zap"
)
const (
// IntroDBBaseURL is the public read endpoint. Overridable on the service
// for tests and for pointing at a mirror.
IntroDBBaseURL = "https://api.theintrodb.org/v3"
// IntroDBSource tags rows that came from this provider.
IntroDBSource = "theintrodb"
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.
// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns
// end_ms: null for end credits); the caller resolves it against the duration.
type IntroDBSpan struct {
Kind string
StartMs int64
EndMs int64
}
// IntroDBService queries TheIntroDB for one media item.
type IntroDBService struct {
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,
retryDelay: introDBRetryDelay,
}
}
// SetBaseURL overrides the API root (tests, mirrors).
func (s *IntroDBService) SetBaseURL(base string) *IntroDBService {
if s != nil && strings.TrimSpace(base) != "" {
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
}
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 {
StartMs *int64 `json:"start_ms"`
EndMs *int64 `json:"end_ms"`
}
type introDBResponse struct {
TMDbID int `json:"tmdb_id"`
Type string `json:"type"`
Intro []introDBRange `json:"intro"`
Recap []introDBRange `json:"recap"`
Credits []introDBRange `json:"credits"`
Preview []introDBRange `json:"preview"`
}
// Fetch returns the skip ranges TheIntroDB knows about. A 404 means the
// database simply has nothing for this title, which is not an error: the
// 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")
}
if tmdbID <= 0 {
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 introDBFetchAttempt{err: err}
}
req.Header.Set("Accept", "application/json")
resp, err := s.client.Do(req)
if err != nil {
return introDBFetchAttempt{err: err}
}
defer func() { _ = resp.Body.Close() }()
switch {
case resp.StatusCode == http.StatusNotFound:
// 「查到但社区库里没有」不是错误,调用方据此写负缓存。
return introDBFetchAttempt{}
case resp.StatusCode < 200 || resp.StatusCode >= 300:
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 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
}
}
func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
var b strings.Builder
b.WriteString(s.baseURL)
b.WriteString("/media?tmdb_id=")
b.WriteString(strconv.Itoa(tmdbID))
// TheIntroDB 对剧集必须带 season+episode,只给 tmdb_id 会返回 404。
if season > 0 && episode > 0 {
b.WriteString("&season=")
b.WriteString(strconv.Itoa(season))
b.WriteString("&episode=")
b.WriteString(strconv.Itoa(episode))
}
return b.String()
}
// parseIntroDBResponse flattens the per-type arrays into spans, preserving the
// intro -> recap -> credits -> preview order so the player sees the earliest
// range first.
func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
var raw introDBResponse
if err := json.Unmarshal(body, &raw); err != nil {
return nil, fmt.Errorf("parse introdb json: %w", err)
}
groups := []struct {
kind string
ranges []introDBRange
}{
{"intro", raw.Intro},
{"recap", raw.Recap},
{"credits", raw.Credits},
{"preview", raw.Preview},
}
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
for _, group := range groups {
for _, r := range group.ranges {
var start int64
if r.StartMs != nil {
start = *r.StartMs
}
var end int64
if r.EndMs != nil {
end = *r.EndMs
}
if start < 0 {
start = 0
}
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
if end != 0 && end <= start {
continue
}
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
}
}
return spans, nil
}
// logIntroDBFailure 只在 debug 级别记录,避免社区库不可达时把日志刷满。
func logIntroDBFailure(log *zap.Logger, tmdbID int, err error) {
if log == nil || err == nil {
return
}
log.Debug("introdb lookup failed", zap.Int("tmdb_id", tmdbID), zap.Error(err))
}