mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-02 04:16:38 +08:00
185 lines
5.3 KiB
Go
185 lines
5.3 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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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.
|
|
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)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("Accept", "application/json")
|
|
resp, err := s.client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
switch {
|
|
case resp.StatusCode == http.StatusNotFound:
|
|
return nil, nil
|
|
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
|
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBMaxBodySize))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseIntroDBResponse(body)
|
|
}
|
|
|
|
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))
|
|
}
|