mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-06 05:16:38 +08:00
添加跳过片头片尾功能
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
|
||||
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
|
||||
const (
|
||||
introDBTVPayload = `{"tmdb_id":1396,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
|
||||
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
|
||||
)
|
||||
|
||||
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
||||
spans, err := parseIntroDBResponse([]byte(introDBTVPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 2 {
|
||||
t.Fatalf("spans = %d, want 2 (%#v)", len(spans), spans)
|
||||
}
|
||||
if spans[0].Kind != "intro" || spans[0].StartMs != 228_664 || spans[0].EndMs != 246_143 {
|
||||
t.Fatalf("intro span = %#v", spans[0])
|
||||
}
|
||||
// end_ms: null 表示一直到片尾,落成 0 由客户端结合时长补齐。
|
||||
if spans[1].Kind != "credits" || spans[1].StartMs != 3_431_000 || spans[1].EndMs != 0 {
|
||||
t.Fatalf("credits span = %#v", spans[1])
|
||||
}
|
||||
|
||||
movie, err := parseIntroDBResponse([]byte(introDBMoviePayload))
|
||||
if err != nil {
|
||||
t.Fatalf("parse movie: %v", err)
|
||||
}
|
||||
if len(movie) != 1 {
|
||||
t.Fatalf("movie spans = %d, want 1", len(movie))
|
||||
}
|
||||
// start_ms: null = 从片头开始。
|
||||
if movie[0].StartMs != 0 || movie[0].EndMs != 38_000 {
|
||||
t.Fatalf("movie intro span = %#v", movie[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
||||
body := `{"tmdb_id":1,"type":"movie",
|
||||
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
|
||||
"recap":[],"credits":[],"preview":[]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
// 只有 end > start 的区间是可跳过的;end == 0(到片尾)是合法值,此处不涉及。
|
||||
if len(spans) != 1 || spans[0].StartMs != 1_000 || spans[0].EndMs != 2_000 {
|
||||
t.Fatalf("spans = %#v, want only the 1000-2000 range", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
||||
body := `{"tmdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
|
||||
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
want := []string{"intro", "recap", "credits"}
|
||||
if len(spans) != len(want) {
|
||||
t.Fatalf("spans = %#v, want %d", spans, len(want))
|
||||
}
|
||||
for i, kind := range want {
|
||||
if spans[i].Kind != kind {
|
||||
t.Fatalf("span[%d].kind = %q, want %q", i, spans[i].Kind, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
|
||||
svc := NewIntroDBService(zap.NewNop())
|
||||
if got, want := svc.mediaURL(1396, 1, 1),
|
||||
"https://api.theintrodb.org/v3/media?tmdb_id=1396&season=1&episode=1"; got != want {
|
||||
t.Fatalf("tv url = %q, want %q", got, want)
|
||||
}
|
||||
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
|
||||
if got, want := svc.mediaURL(27205, 0, 0),
|
||||
"https://api.theintrodb.org/v3/media?tmdb_id=27205"; got != want {
|
||||
t.Fatalf("movie url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchTreatsNotFoundAsNoData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.Fetch(t.Context(), 999_999, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("404 must not be an error, got %v", err)
|
||||
}
|
||||
if len(spans) != 0 {
|
||||
t.Fatalf("spans = %#v, want none", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchReportsUnexpectedStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
if _, err := svc.Fetch(t.Context(), 1, 0, 0); err == nil {
|
||||
t.Fatal("500 should surface as an error so the caller can keep its cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchSkipsRequestWithoutTMDbID(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.Fetch(t.Context(), 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(spans) != 0 || calls != 0 {
|
||||
t.Fatalf("spans = %#v calls = %d, want no request without a tmdb id", spans, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchParsesBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("tmdb_id"); got != "1396" {
|
||||
t.Errorf("tmdb_id = %q, want 1396", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("season"); got != "1" {
|
||||
t.Errorf("season = %q, want 1", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("episode"); got != "1" {
|
||||
t.Errorf("episode = %q, want 1", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBTVPayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.Fetch(t.Context(), 1396, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(spans) != 2 || spans[0].Kind != "intro" {
|
||||
t.Fatalf("spans = %#v", spans)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package service — 片头/片尾片段(intro / recap / credits / preview)。
|
||||
//
|
||||
// 播放器只认本地库里的片段数据;外部提供方(当前为 TheIntroDB)在播放时按需
|
||||
// 补齐并落库,因此同一部片第二次播放时不再产生任何外网请求。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久;
|
||||
// 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则
|
||||
// 每次播放一部没有片段数据的影片都会打一次外网。
|
||||
const (
|
||||
segmentFoundTTL = 30 * 24 * time.Hour
|
||||
segmentMissingTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// SegmentView 是播放器消费的最小片段结构,避免把库内字段(source 等)暴露给前端。
|
||||
type SegmentView struct {
|
||||
Kind string `json:"kind"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
}
|
||||
|
||||
// ToSegmentViews 转换库内行为对外视图。
|
||||
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
|
||||
out := make([]SegmentView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
|
||||
type MediaSegmentService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
introdb *IntroDBService
|
||||
}
|
||||
|
||||
// NewMediaSegmentService is the constructor.
|
||||
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
|
||||
return &MediaSegmentService{log: log, repo: repo}
|
||||
}
|
||||
|
||||
// SetIntroDB wires the provider. Without it the service only reads cached rows.
|
||||
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.introdb = p
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListForPlayback returns the segments known for a media item, refreshing from
|
||||
// the provider when the cache is stale.
|
||||
//
|
||||
// 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的,
|
||||
// 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。
|
||||
func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
if s == nil || s.repo == nil || m == nil || m.ID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cached, err := s.repo.MediaSegment.ListByMedia(ctx, m.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ledger != nil && ledgerFresh(ledger) {
|
||||
return cached, nil
|
||||
}
|
||||
refreshed, attempted, err := s.refresh(ctx, m)
|
||||
if err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
return cached, nil
|
||||
}
|
||||
if !attempted {
|
||||
return cached, nil
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
|
||||
//
|
||||
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
|
||||
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
|
||||
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
|
||||
if s.introdb == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tmdbID, season, episode := s.queryIDs(ctx, m)
|
||||
if tmdbID <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
|
||||
// 要落库,这样下一次播放直接命中缓存。
|
||||
//
|
||||
// 但 WithoutCancel 会丢掉 deadline,所以这里要主动把调用方原本愿意等待的
|
||||
// 剩余时间取回来:Emby 等第三方客户端会在起播前后同步请求片段,若调用方只
|
||||
// 打算等 5 秒,不能因为一次外网抓取把它拖到 10 秒。
|
||||
budget := introDBTimeout + 2*time.Second
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(deadline); remaining < budget {
|
||||
budget = remaining
|
||||
}
|
||||
}
|
||||
if budget <= 0 {
|
||||
// 调用方的预算已经用完:直接放弃本次抓取。返回 attempted=false,
|
||||
// 调用方保留自己的缓存,也不会写入负缓存(下次还有机会)。
|
||||
return nil, false, nil
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
|
||||
defer cancel()
|
||||
|
||||
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
rows := make([]model.MediaSegment, 0, len(spans))
|
||||
for _, span := range spans {
|
||||
rows = append(rows, model.MediaSegment{
|
||||
MediaID: m.ID,
|
||||
SeriesID: m.SeriesID,
|
||||
Kind: span.Kind,
|
||||
StartMs: span.StartMs,
|
||||
EndMs: span.EndMs,
|
||||
Source: IntroDBSource,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
|
||||
MediaID: m.ID,
|
||||
Source: IntroDBSource,
|
||||
FetchedAt: time.Now(),
|
||||
Found: len(rows) > 0,
|
||||
}); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
if m.SeriesID == "" || m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
|
||||
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 {
|
||||
return m.TMDbID, 0, 0
|
||||
}
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// ledgerFresh reports whether a previous lookup is still within its TTL.
|
||||
func ledgerFresh(row *model.MediaSegmentFetch) bool {
|
||||
if row == nil {
|
||||
return false
|
||||
}
|
||||
ttl := segmentMissingTTL
|
||||
if row.Found {
|
||||
ttl = segmentFoundTTL
|
||||
}
|
||||
return time.Since(row.FetchedAt) < ttl
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newSegmentServiceFixture(t *testing.T, handler http.HandlerFunc) (*MediaSegmentService, *repository.Container, *int32) {
|
||||
t.Helper()
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
handler(w, r)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
return svc, repos, &calls
|
||||
}
|
||||
|
||||
func writeJSONBody(body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集必须用「剧集级」TMDb id 查询,而 Media.TMDbID 存的是单集自己的 id:
|
||||
// 刮削写的是 episode 的 tmdb id(见 local_metadata_test.go 的约束)。
|
||||
func TestQueryIDsUsesSeriesTMDbForEpisodes(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: "Breaking Bad", TMDbID: 1396,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episode := &model.Media{
|
||||
Base: model.Base{ID: "ep-1"},
|
||||
SeriesID: "s-1",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
TMDbID: 4375419, // 单集 id,不是剧集 id
|
||||
}
|
||||
tmdbID, season, episodeNum := svc.queryIDs(ctx, episode)
|
||||
if tmdbID != 1396 {
|
||||
t.Fatalf("tmdbID = %d, want the series id 1396 (not the episode id)", tmdbID)
|
||||
}
|
||||
if season != 1 || episodeNum != 2 {
|
||||
t.Fatalf("season/episode = %d/%d, want 1/2", season, episodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIDsForMovieUsesOwnTMDb(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
|
||||
tmdbID, season, episode := svc.queryIDs(t.Context(), &model.Media{
|
||||
Base: model.Base{ID: "mv-1"}, TMDbID: 27205,
|
||||
})
|
||||
if tmdbID != 27205 || season != 0 || episode != 0 {
|
||||
t.Fatalf("query = (%d,%d,%d), want (27205,0,0)", tmdbID, season, episode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIDsIsNotResolvableBeforeScrape(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
// 剧集还没关联 Series:解析不出来,但也不能当成「查过且没有」。
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, &model.Media{
|
||||
Base: model.Base{ID: "ep-orphan"}, SeasonNum: 1, EpisodeNum: 1,
|
||||
}); tmdbID != 0 {
|
||||
t.Fatalf("tmdbID = %d, want 0", tmdbID)
|
||||
}
|
||||
// 没刮削过的电影同理。
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, &model.Media{Base: model.Base{ID: "mv-noscrape"}}); tmdbID != 0 {
|
||||
t.Fatalf("tmdbID = %d, want 0", tmdbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackFetchesOnceThenServesCache(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-1"}, Path: "/movies/inception.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rows, err := svc.ListForPlayback(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("call #%d: %v", i+1, err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("call #%d rows = %#v, want 1", i+1, rows)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (later plays must hit the local cache)", got)
|
||||
}
|
||||
got, err := repos.MediaSegment.ListByMedia(ctx, "mv-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Kind != model.SegmentKindIntro || got[0].StartMs != 0 || got[0].EndMs != 38_000 {
|
||||
t.Fatalf("persisted rows = %#v", got)
|
||||
}
|
||||
if got[0].Source != IntroDBSource {
|
||||
t.Fatalf("source = %q, want %q", got[0].Source, IntroDBSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackCachesMisses(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-2"}, Path: "/movies/nobody-knows.mkv", TMDbID: 424242}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
rows, err := svc.ListForPlayback(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("call #%d: %v", i+1, err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("call #%d rows = %#v, want none", i+1, rows)
|
||||
}
|
||||
}
|
||||
// 负缓存是必需的:否则每次播放这部片都会重新打一次外网。
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (a miss must be cached too)", got)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-2", IntroDBSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger == nil || ledger.Found {
|
||||
t.Fatalf("ledger = %#v, want a recorded miss", ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackSkipsProviderWithoutExternalID(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-3"}, Path: "/movies/unscraped.mkv"}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := svc.ListForPlayback(ctx, m); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0 without a tmdb id", got)
|
||||
}
|
||||
// 关键:解析不出外部 ID 时不能写负缓存,否则刮削完成后就永远不会再查了。
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-3", IntroDBSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger != nil {
|
||||
t.Fatalf("ledger = %#v, want none while metadata is still missing", ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackKeepsCacheWhenProviderFails(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-4"}, Path: "/movies/flaky.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if rows, err := svc.ListForPlayback(ctx, m); err != nil || len(rows) != 1 {
|
||||
t.Fatalf("first call rows=%#v err=%v", rows, err)
|
||||
}
|
||||
// 让缓存过期,制造一次会失败的刷新。
|
||||
if err := repos.DB.Model(&model.MediaSegmentFetch{}).
|
||||
Where("media_id = ?", "mv-4").
|
||||
Update("fetched_at", time.Now().Add(-segmentFoundTTL-time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := svc.ListForPlayback(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("provider failure must not surface as an error: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %#v, want the previous cache kept", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
|
||||
// 第三方客户端(Emby)会在起播路径上同步请求片段,它给的超时必须生效,
|
||||
// 不能被一次外网抓取拖住;同时超时不能变成「负缓存」,否则就再也补不上了。
|
||||
svc, repos, calls := newSegmentServiceFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
})
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-5"}, Path: "/movies/budget.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
budgeted, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
rows, err := svc.ListForPlayback(budgeted, m)
|
||||
if err != nil {
|
||||
t.Fatalf("an exhausted fetch budget must not surface as an error: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("rows = %#v, want none when the caller's budget ran out", rows)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (the attempt was made then abandoned)", got)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-5", IntroDBSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger != nil {
|
||||
t.Fatal("a timed-out fetch must not be recorded as a negative cache entry")
|
||||
}
|
||||
|
||||
// 预算正常时(下一次播放)仍然能补上。
|
||||
rows, err = svc.ListForPlayback(ctx, m)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("second call rows=%#v err=%v, want the fetched segment", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||||
now := time.Now()
|
||||
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true}
|
||||
if !ledgerFresh(found) {
|
||||
t.Fatal("a hit should still be fresh just past the miss TTL")
|
||||
}
|
||||
miss := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: false}
|
||||
if ledgerFresh(miss) {
|
||||
t.Fatal("a miss should expire after the miss TTL")
|
||||
}
|
||||
if ledgerFresh(nil) {
|
||||
t.Fatal("a missing ledger must not be considered fresh")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type Container struct {
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Playback *PlaybackService
|
||||
Segments *MediaSegmentService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Subtitle *SubtitleService
|
||||
|
||||
@@ -130,6 +130,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
|
||||
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
|
||||
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
|
||||
// 片头/片尾片段:播放时按需向 TheIntroDB 补齐并落库,供下次直接命中。
|
||||
b.c.Segments = NewMediaSegmentService(b.log, b.repos).SetIntroDB(NewIntroDBService(b.log))
|
||||
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
|
||||
Reference in New Issue
Block a user