添加跳过片头片尾功能

This commit is contained in:
truewhile
2026-09-23 10:22:13 +08:00
parent 016c6687e9
commit 94ef0116b1
26 changed files with 2115 additions and 4 deletions
+184
View File
@@ -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))
}
+162
View File
@@ -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)
}
}
+184
View File
@@ -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
}
+276
View File
@@ -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")
}
}
+1
View File
@@ -35,6 +35,7 @@ type Container struct {
Fanart *FanartProvider
Scraper *ScraperService
Playback *PlaybackService
Segments *MediaSegmentService
ImageProxy *ImageProxy
Watcher *WatcherService
Subtitle *SubtitleService
+2
View File
@@ -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)