Files
MeBox/internal/service/introdb.go
T
truewhile 9ccf60427a 优化
2026-10-22 15:14:00 +08:00

309 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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. v1 已删除,v2/v3 冻结在只读的
// GET /media 上并被官方标记为弃用,所以这里直接跟 v4。
IntroDBBaseURL = "https://api.theintrodb.org/v4"
// 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
)
// 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"`
}
// introDBResponse 对应 v4 的 MediaResponse。v4 以 TVDB 为身份,响应只带
// tvdb_id —— tmdb_id 字段已被官方移除,需要 tmdb_id 只能从请求侧自己记。
type introDBResponse struct {
TVDBID int `json:"tvdb_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) ([]SegmentSpan, 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
}
}
}
// Source implements SegmentProvider.
func (s *IntroDBService) Source() string { return IntroDBSource }
// FetchSegments implements SegmentProvider. TheIntroDB 以 TMDb id 寻址。
func (s *IntroDBService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
if q.IsMovie {
return s.Fetch(ctx, q.TMDbID, 0, 0)
}
return s.Fetch(ctx, q.TMDbID, q.Season, q.Episode)
}
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
type introDBFetchAttempt struct {
spans []SegmentSpan
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) ([]SegmentSpan, 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([]SegmentSpan, 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
}
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)区分了两种
// 「没有数值」的写法,含义完全不同,必须按指针区分而不能都归零:
// end_ms: null —— Credits/Preview 专用,「一直到片尾」,合法,
// 落成 0 作为哨兵值由客户端结合片长补齐;
// end_ms: 0 —— 「no segment」,即这一段不存在。
// 早期实现把两者都当成「到片尾」,于是 intro:[{start_ms:null,end_ms:0}]
// 被解释成 [0, 片长],整集都落在「片头」区间里:一开播就弹跳过按钮,
// 开了自动跳过更是直接跳到片尾。实测约三分之一的剧集会返回这种占位。
if r.EndMs != nil && end <= start {
continue
}
// 只有片尾类区间才允许「开放结束点」(end_ms: null 落成 0)。片头/回顾
// 如果出现 null 结束点,补齐后的区间会覆盖到片尾,等于把整集判成片头,
// 所以宁可不显示也不猜。
if r.EndMs == nil && group.kind != "credits" && group.kind != "preview" {
continue
}
spans = append(spans, SegmentSpan{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))
}