mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
309 lines
10 KiB
Go
309 lines
10 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. 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))
|
||
}
|