mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。
|
||||
// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。
|
||||
func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) {
|
||||
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
libID := seedEmbyLibrary(t, svc, "movie")
|
||||
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
|
||||
Base: model.Base{ID: "movie-1"},
|
||||
LibraryID: libID,
|
||||
Title: "Movie One",
|
||||
Path: "/media/movie/one.mp4",
|
||||
Year: 2024,
|
||||
ReleaseDate: "2024-01-01",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create movie: %v", err)
|
||||
}
|
||||
|
||||
path := "/emby/Items?ParentId=" + libID +
|
||||
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
|
||||
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
|
||||
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String())
|
||||
}
|
||||
if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" {
|
||||
t.Fatalf("unexpected item payload: %#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。
|
||||
func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) {
|
||||
router, svc, _ := newEmbyDiscoveryEnv(t)
|
||||
if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
for _, ep := range []struct {
|
||||
id string
|
||||
num int
|
||||
path string
|
||||
}{
|
||||
{id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"},
|
||||
{id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"},
|
||||
} {
|
||||
if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{
|
||||
Base: model.Base{ID: ep.id},
|
||||
LibraryID: libID,
|
||||
SeriesID: "series-1",
|
||||
Title: "Show",
|
||||
Path: ep.path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: ep.num,
|
||||
Year: 2024,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create episode: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
path := "/emby/Items?ParentId=" + libID +
|
||||
"&IncludeItemTypes=Movie,Series,Video&Recursive=true" +
|
||||
"&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0"
|
||||
w := embyGet(t, router, path, signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String())
|
||||
}
|
||||
if items[0]["Type"] != "Series" {
|
||||
t.Fatalf("expected Series card, got %#v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转;
|
||||
// 返回 {"Items":[]} 会直接抛 type cast 异常。
|
||||
func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) {
|
||||
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||
w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' {
|
||||
t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String())
|
||||
}
|
||||
var items []any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil {
|
||||
t.Fatalf("decode array: %v body=%s", err, w.Body.String())
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("expected empty array, got %d entries", len(items))
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string
|
||||
}
|
||||
endMs := row.EndMs
|
||||
if endMs <= 0 {
|
||||
// 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间
|
||||
// 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把
|
||||
// 整集标成片头,所以直接丢弃。
|
||||
if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview {
|
||||
continue
|
||||
}
|
||||
if durationMs <= 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
||||
auth.GET("/Items/:id/Similar", embySimilarHandler(svc))
|
||||
auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
// SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组),
|
||||
// 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。
|
||||
auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc))
|
||||
}
|
||||
|
||||
@@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
||||
auth.GET("/items/:id/similar", embySimilarHandler(svc))
|
||||
auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc))
|
||||
auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
// Package handler — 片头/片尾片段接口。
|
||||
//
|
||||
// GET /playback/:id/segments
|
||||
// GET /playback/:id/segments
|
||||
// PUT /playback/:id/segments {kind, position_ms, clear}
|
||||
//
|
||||
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
|
||||
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
|
||||
// 远程 Emby)无关,独立出来对所有媒体一致。
|
||||
//
|
||||
// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。
|
||||
// 外部数据永远由提供方刷新,不接受客户端写入。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// playbackSegmentsMarkRequest 是手动标记的入参。
|
||||
type playbackSegmentsMarkRequest struct {
|
||||
// Kind 只接受 intro / credits。
|
||||
Kind string `json:"kind" binding:"required"`
|
||||
// PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。
|
||||
PositionMs int64 `json:"position_ms"`
|
||||
// Clear 为 true 时删除该类型的标记。
|
||||
Clear bool `json:"clear"`
|
||||
}
|
||||
|
||||
// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。
|
||||
//
|
||||
// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级
|
||||
// 最高),避免自己再发一次 GET 造成竞态。
|
||||
func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if svc.Segments == nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"})
|
||||
return
|
||||
}
|
||||
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "media not found"})
|
||||
return
|
||||
}
|
||||
// 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。
|
||||
if service.IsEmbyRemoteID(m.ID) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"})
|
||||
return
|
||||
}
|
||||
var req playbackSegmentsMarkRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"})
|
||||
return
|
||||
}
|
||||
err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{
|
||||
Kind: req.Kind,
|
||||
PositionMs: req.PositionMs,
|
||||
Clear: req.Clear,
|
||||
})
|
||||
if errors.Is(err, service.ErrInvalidManualMark) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Warn("save manual skip mark failed",
|
||||
zap.String("media_id", m.ID), zap.Error(err))
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"})
|
||||
return
|
||||
}
|
||||
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
|
||||
if listErr != nil {
|
||||
if svc.Log != nil {
|
||||
svc.Log.Debug("list media segments after mark failed",
|
||||
zap.String("media_id", m.ID), zap.Error(listErr))
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"segments": service.ToSegmentViews(rows),
|
||||
"auto_skip": resolveAutoSkipFlag(c, svc),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is
|
||||
// currently in effect. It reuses selectedPlayProfile so the server agrees with
|
||||
// the UI about which profile is active (explicit header first, then the user's
|
||||
|
||||
@@ -74,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
|
||||
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
|
||||
authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
|
||||
@@ -237,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media
|
||||
} else {
|
||||
libraryID := strings.TrimSpace(m.LibraryID)
|
||||
title := strings.TrimSpace(m.Title)
|
||||
if libraryID == "" || title == "" || m.TMDbID <= 0 {
|
||||
if libraryID == "" || title == "" {
|
||||
return nil, nil
|
||||
}
|
||||
query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID)
|
||||
// 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季
|
||||
// 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个
|
||||
// 功能在未刮削的库上完全不生效。
|
||||
query = query.Where("library_id = ? AND title = ?", libraryID, title)
|
||||
if m.TMDbID > 0 {
|
||||
query = query.Where("tm_db_id = ?", m.TMDbID)
|
||||
}
|
||||
}
|
||||
var rows []model.Media
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
|
||||
@@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s
|
||||
})
|
||||
}
|
||||
|
||||
// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind)
|
||||
// triple in a single transaction. Used by manual marking, where the user sets
|
||||
// one boundary at a time and must not disturb the other kinds.
|
||||
func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error {
|
||||
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Unscoped().
|
||||
Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind).
|
||||
Delete(&model.MediaSegment{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Create(&rows).Error
|
||||
})
|
||||
}
|
||||
|
||||
// GetFetch returns the fetch ledger row for (media, source), or (nil, nil).
|
||||
func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) {
|
||||
var row model.MediaSegmentFetch
|
||||
|
||||
@@ -345,8 +345,24 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any,
|
||||
}
|
||||
}
|
||||
|
||||
// 客户端刷新首页时常用 Movie,Series,Video 这种混合类型一次取「最新」行。
|
||||
// 只有剧集库才该折叠成剧集卡片:seriesItemsForLibrary 只查
|
||||
// (season_num>0 OR episode_num>0),电影库走到这里会让整行返回 0 条。
|
||||
if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") {
|
||||
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
|
||||
episodic := true
|
||||
if p.ParentID != "" {
|
||||
if value, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil {
|
||||
episodic = value
|
||||
}
|
||||
}
|
||||
if episodic {
|
||||
return e.seriesItemsForLibrary(ctx, p.ParentID, p)
|
||||
}
|
||||
// 非剧集库:只请求 Series 时确实没有可返回的条目;混合类型则落回
|
||||
// mediaItems,由它按 Movie 过滤,而不是整行判空。
|
||||
if !containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Video") {
|
||||
return emptyItemsEnvelope(p.StartIndex), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按
|
||||
|
||||
@@ -144,3 +144,43 @@ func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int,
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
// segmentRangeCleanupSettingKey marks the one-time removal of impossible
|
||||
// skip ranges written by the older end_ms: 0 interpretation.
|
||||
const segmentRangeCleanupSettingKey = "segment.range_cleanup_v1_done"
|
||||
|
||||
// NormalizeImpossibleSegments 删除历史误写进来的「不可能区间」。
|
||||
//
|
||||
// 旧解析把 TheIntroDB 的 end_ms: 0 当成「延续到片尾」,于是
|
||||
// intro:[{start_ms:null,end_ms:0}] 被落成 intro (0, 0),客户端补齐后覆盖整集;
|
||||
// 同理 recap (0,0) 也会让整集变成回顾。这类行只会让播放器一开播就弹跳过按钮,
|
||||
// 没有任何保留价值,直接硬删。
|
||||
//
|
||||
// 幂等:完成后写 setting 标记。只处理片头/回顾——片尾类允许开放结束点。
|
||||
func (c *Container) NormalizeImpossibleSegments(ctx context.Context) (int, error) {
|
||||
if c == nil || c.Repo == nil || c.Repo.DB == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if c.Repo.Setting != nil {
|
||||
if v, err := c.Repo.Setting.Get(ctx, segmentRangeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" {
|
||||
return 0, nil
|
||||
}
|
||||
}
|
||||
res := c.Repo.DB.WithContext(ctx).Unscoped().
|
||||
Where("kind IN ?", []string{model.SegmentKindIntro, model.SegmentKindRecap}).
|
||||
Where("end_ms <= start_ms").
|
||||
Delete(&model.MediaSegment{})
|
||||
if res.Error != nil {
|
||||
return 0, res.Error
|
||||
}
|
||||
cleaned := int(res.RowsAffected)
|
||||
if c.Repo.Setting != nil {
|
||||
if err := c.Repo.Setting.Set(ctx, segmentRangeCleanupSettingKey, "true"); err != nil && c.Log != nil {
|
||||
c.Log.Warn("mark segment range cleanup done failed", zap.Error(err))
|
||||
}
|
||||
}
|
||||
if cleaned > 0 && c.Log != nil {
|
||||
c.Log.Info("impossible skip segments removed", zap.Int("rows", cleaned))
|
||||
}
|
||||
return cleaned, nil
|
||||
}
|
||||
|
||||
+31
-17
@@ -41,15 +41,6 @@ const (
|
||||
introDBMaxRetryDelay = 3 * time.Second
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -113,7 +104,7 @@ type introDBResponse struct {
|
||||
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
|
||||
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
|
||||
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
|
||||
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
|
||||
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")
|
||||
}
|
||||
@@ -135,9 +126,20 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 []IntroDBSpan
|
||||
spans []SegmentSpan
|
||||
err error
|
||||
retryable bool
|
||||
retryAfter time.Duration
|
||||
@@ -246,7 +248,7 @@ func (s *IntroDBService) mediaURL(tmdbID, season, episode int) 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) {
|
||||
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)
|
||||
@@ -260,7 +262,7 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
|
||||
{"credits", raw.Credits},
|
||||
{"preview", raw.Preview},
|
||||
}
|
||||
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
|
||||
spans := make([]SegmentSpan, 0, len(raw.Intro)+len(raw.Credits))
|
||||
for _, group := range groups {
|
||||
for _, r := range group.ranges {
|
||||
var start int64
|
||||
@@ -274,12 +276,24 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
|
||||
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
|
||||
if end != 0 && end <= start {
|
||||
// 官方规范(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
|
||||
}
|
||||
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
|
||||
// 只有片尾类区间才允许「开放结束点」(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
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// Package service — IntroDB (https://introdb.app) segment client.
|
||||
//
|
||||
// 与 TheIntroDB 是两家不同的社区库:IntroDB 只接受 IMDb id 定位媒体,
|
||||
// 用 is_movie 区分电影与剧集,响应把每种区间直接给成 {start_ms,end_ms} 对象
|
||||
// (而不是按类型分数组),片尾字段名是 outro 而不是 credits,
|
||||
// 另外还有 post_credits(片尾彩蛋,映射为预告/额外片尾)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// IntroDBAppBaseURL 是公开只读端点。POST /submit 需要 API key,这里不做写入。
|
||||
const IntroDBAppBaseURL = "https://api.introdb.app"
|
||||
|
||||
// IntroDBAppSource 写入 media_segments.source,与 TheIntroDB 的 "theintrodb" 区分。
|
||||
const IntroDBAppSource = "introdb"
|
||||
|
||||
const (
|
||||
introDBAppTimeout = 8 * time.Second
|
||||
introDBAppMaxBodySize = 1 << 20
|
||||
)
|
||||
|
||||
// introDBAppSegment 是 IntroDB 的单个区间对象。null 表示该类型没有数据。
|
||||
type introDBAppSegment struct {
|
||||
StartMs *int64 `json:"start_ms"`
|
||||
EndMs *int64 `json:"end_ms"`
|
||||
}
|
||||
|
||||
// introDBAppResponse 对应 GET /segments。
|
||||
type introDBAppResponse struct {
|
||||
IMDbID string `json:"imdb_id"`
|
||||
MediaType string `json:"media_type"`
|
||||
IsMovie bool `json:"is_movie"`
|
||||
Season int `json:"season"`
|
||||
Episode int `json:"episode"`
|
||||
Intro *introDBAppSegment `json:"intro"`
|
||||
Recap *introDBAppSegment `json:"recap"`
|
||||
Outro *introDBAppSegment `json:"outro"`
|
||||
PostCredits *introDBAppSegment `json:"post_credits"`
|
||||
}
|
||||
|
||||
// IntroDBAppService 查询 IntroDB 的片段数据。
|
||||
type IntroDBAppService struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewIntroDBAppService is the constructor. The client honours environment and
|
||||
// OS proxy settings so it behaves like the other third-party API clients.
|
||||
func NewIntroDBAppService(log *zap.Logger) *IntroDBAppService {
|
||||
return &IntroDBAppService{
|
||||
log: log,
|
||||
client: NewExternalHTTPClient(introDBAppTimeout),
|
||||
baseURL: IntroDBAppBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL overrides the API root (tests, mirrors).
|
||||
func (s *IntroDBAppService) SetBaseURL(base string) *IntroDBAppService {
|
||||
if s != nil && strings.TrimSpace(base) != "" {
|
||||
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Source implements SegmentProvider.
|
||||
func (s *IntroDBAppService) Source() string { return IntroDBAppSource }
|
||||
|
||||
// FetchSegments implements SegmentProvider.
|
||||
//
|
||||
// 没有 IMDb id 时直接返回空:IntroDB 不接受 TMDb id,用别的 id 拼请求只会拿到
|
||||
// 400,那会被误记成「查询失败」从而保留旧缓存、永远不再刷新。
|
||||
func (s *IntroDBAppService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
|
||||
if s == nil || s.client == nil || strings.TrimSpace(q.IMDbID) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint := s.segmentsURL(q)
|
||||
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() }()
|
||||
|
||||
// 404 = 库里没有这部片,是正常的「无数据」,不是错误。
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBAppMaxBodySize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseIntroDBAppResponse(body)
|
||||
}
|
||||
|
||||
func (s *IntroDBAppService) segmentsURL(q SegmentQuery) string {
|
||||
values := url.Values{}
|
||||
values.Set("imdb_id", strings.TrimSpace(q.IMDbID))
|
||||
if q.IsMovie {
|
||||
values.Set("is_movie", "true")
|
||||
} else if q.Season > 0 && q.Episode > 0 {
|
||||
values.Set("season", strconv.Itoa(q.Season))
|
||||
values.Set("episode", strconv.Itoa(q.Episode))
|
||||
}
|
||||
return s.baseURL + "/segments?" + values.Encode()
|
||||
}
|
||||
|
||||
// parseIntroDBAppResponse 把 IntroDB 的四个区间对象摊平成 spans。
|
||||
//
|
||||
// 顺序固定为 intro -> recap -> outro -> post_credits,与 TheIntroDB 的
|
||||
// intro/recap/credits/preview 对齐,后处理逻辑因此可以对来源无感。
|
||||
func parseIntroDBAppResponse(body []byte) ([]SegmentSpan, error) {
|
||||
var raw introDBAppResponse
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse introdb json: %w", err)
|
||||
}
|
||||
groups := []struct {
|
||||
kind string
|
||||
seg *introDBAppSegment
|
||||
}{
|
||||
{"intro", raw.Intro},
|
||||
{"recap", raw.Recap},
|
||||
{"credits", raw.Outro},
|
||||
{"preview", raw.PostCredits},
|
||||
}
|
||||
spans := make([]SegmentSpan, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
if group.seg == nil || group.seg.StartMs == nil || group.seg.EndMs == nil {
|
||||
continue
|
||||
}
|
||||
start, end := *group.seg.StartMs, *group.seg.EndMs
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if end <= start {
|
||||
continue
|
||||
}
|
||||
spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end})
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newManualMarkFixture(t *testing.T) (*MediaSegmentService, *repository.Container, *model.Media) {
|
||||
t.Helper()
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "ep-1"},
|
||||
Title: "Show",
|
||||
Path: "/tv/show/s01e01.mkv",
|
||||
LibraryID: "lib-1",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return svc, repos, m
|
||||
}
|
||||
|
||||
func TestSetManualMarkWritesIntroBoundary(t *testing.T) {
|
||||
svc, repos, m := newManualMarkFixture(t)
|
||||
if err := svc.SetManualMark(t.Context(), m, ManualMarkRequest{
|
||||
Kind: model.SegmentKindIntro, PositionMs: 90_000,
|
||||
}); err != nil {
|
||||
t.Fatalf("set mark: %v", err)
|
||||
}
|
||||
rows, err := repos.MediaSegment.ListByMedia(t.Context(), m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %#v, want 1", rows)
|
||||
}
|
||||
if rows[0].Kind != model.SegmentKindIntro || rows[0].Source != SegmentSourceManual {
|
||||
t.Fatalf("row = %#v, want manual intro", rows[0])
|
||||
}
|
||||
if rows[0].StartMs != 0 || rows[0].EndMs != 90_000 {
|
||||
t.Fatalf("row = %#v, want 0-90000ms", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetManualMarkClearRemovesOnlyThatKind(t *testing.T) {
|
||||
svc, repos, m := newManualMarkFixture(t)
|
||||
ctx := t.Context()
|
||||
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, PositionMs: 90_000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindCredits, PositionMs: 1_200_000}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, Clear: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := repos.MediaSegment.ListByMedia(ctx, m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Kind != model.SegmentKindCredits {
|
||||
t.Fatalf("rows = %#v, want only the credits mark to survive", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetManualMarkRejectsInvalidRequests(t *testing.T) {
|
||||
svc, _, m := newManualMarkFixture(t)
|
||||
cases := []ManualMarkRequest{
|
||||
{Kind: "recap", PositionMs: 1000}, // 只支持 intro/credits
|
||||
{Kind: "preview", PositionMs: 1000}, // 同上
|
||||
{Kind: model.SegmentKindIntro, PositionMs: 0},
|
||||
{Kind: model.SegmentKindIntro, PositionMs: -5},
|
||||
}
|
||||
for _, req := range cases {
|
||||
if err := svc.SetManualMark(t.Context(), m, req); err == nil {
|
||||
t.Fatalf("req %#v: want an error", req)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 同一季的其它集应自动继承手动标记,但用户已显式标过的集不能被覆盖。
|
||||
func TestSetManualMarkPropagatesToSeasonSiblings(t *testing.T) {
|
||||
svc, repos, m := newManualMarkFixture(t)
|
||||
ctx := t.Context()
|
||||
sibling := &model.Media{
|
||||
Base: model.Base{ID: "ep-2"},
|
||||
Title: "Show",
|
||||
Path: "/tv/show/s01e02.mkv",
|
||||
LibraryID: "lib-1",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
}
|
||||
other := &model.Media{
|
||||
Base: model.Base{ID: "ep-3"},
|
||||
Title: "Show",
|
||||
Path: "/tv/show/s01e03.mkv",
|
||||
LibraryID: "lib-1",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 3,
|
||||
}
|
||||
if err := repos.DB.Create(sibling).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.DB.Create(other).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// other 已经有自己的手动片头标记,不该被覆盖。
|
||||
if err := svc.SetManualMark(ctx, other, ManualMarkRequest{
|
||||
Kind: model.SegmentKindIntro, PositionMs: 45_000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
|
||||
Kind: model.SegmentKindIntro, PositionMs: 90_000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sibRows, err := repos.MediaSegment.ListByMedia(ctx, sibling.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sibRows) != 1 || sibRows[0].EndMs != 90_000 {
|
||||
t.Fatalf("sibling rows = %#v, want the propagated 90000ms mark", sibRows)
|
||||
}
|
||||
|
||||
otherRows, err := repos.MediaSegment.ListByMedia(ctx, other.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(otherRows) != 1 || otherRows[0].EndMs != 45_000 {
|
||||
t.Fatalf("other rows = %#v, want its own 45000ms mark preserved", otherRows)
|
||||
}
|
||||
}
|
||||
|
||||
// 手动标记优先级最高:即使外部源给了不同位置,播放时也只输出手动那条。
|
||||
func TestManualMarkOutranksExternalSource(t *testing.T) {
|
||||
svc, repos, m := newManualMarkFixture(t)
|
||||
ctx := t.Context()
|
||||
if err := repos.MediaSegment.ReplaceForMedia(ctx, m.ID, IntroDBSource, []model.MediaSegment{{
|
||||
MediaID: m.ID, Kind: model.SegmentKindIntro, StartMs: 0, EndMs: 30_000, Source: IntroDBSource,
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.SetManualMark(ctx, m, ManualMarkRequest{
|
||||
Kind: model.SegmentKindIntro, PositionMs: 90_000,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := svc.mergeForPlayback(ctx, m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Source != SegmentSourceManual || rows[0].EndMs != 90_000 {
|
||||
t.Fatalf("rows = %#v, want the manual mark to win", rows)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -43,9 +44,20 @@ type SegmentView struct {
|
||||
}
|
||||
|
||||
// ToSegmentViews 转换库内行为对外视图。
|
||||
//
|
||||
// 顺手丢掉明显不可能的区间:库里的历史数据可能是在 end_ms: 0 被误当成「到片尾」
|
||||
// 的时期写下的(那会把整集变成片头),这些行如果原样下发,播放器仍然会整集弹
|
||||
// 跳过按钮。只有片尾类允许开放结束点(EndMs == 0),其余要求 EndMs > StartMs。
|
||||
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
|
||||
out := make([]SegmentView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
openEnded := row.Kind == model.SegmentKindCredits || row.Kind == model.SegmentKindPreview
|
||||
if row.StartMs < 0 {
|
||||
continue
|
||||
}
|
||||
if row.EndMs <= row.StartMs && !(openEnded && row.EndMs <= 0) {
|
||||
continue
|
||||
}
|
||||
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
|
||||
}
|
||||
return out
|
||||
@@ -53,16 +65,36 @@ func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
|
||||
|
||||
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
|
||||
type MediaSegmentService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
introdb *IntroDBService
|
||||
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
// probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用外部源。
|
||||
probe *MediaProbeService
|
||||
// prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。
|
||||
// prewarmGap 是预热两次请求之间的间隔;测试可设为 0。
|
||||
prewarmGap time.Duration
|
||||
now func() time.Time
|
||||
// providers 是已启用的外部片段源,顺序即注册顺序。
|
||||
// 由设置项 segment.source 决定实际使用哪一个,见 activeProvider。
|
||||
providers []SegmentProvider
|
||||
// imdb 用于给只认 IMDb id 的源(IntroDB)解析外部 id。未注入时该源被跳过。
|
||||
imdb IMDbIDResolver
|
||||
}
|
||||
|
||||
// IMDbIDResolver 把 TMDb 条目解析成 IMDb id(ttXXXXXXX)。
|
||||
// 由 TMDbProvider 实现;抽出接口是为了让 service 层不直接依赖配置与 API key。
|
||||
type IMDbIDResolver interface {
|
||||
ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error)
|
||||
}
|
||||
|
||||
// 片段数据源设置项。取值形如 off / theintrodb / introdb / todb,
|
||||
// 与播放器档案里的「自动跳过」是两件独立的事。
|
||||
const SegmentSourceSettingKey = "segment.source"
|
||||
|
||||
// SegmentSourceOff 表示不查询任何外部源,只消费已落库的数据(手动标记等)。
|
||||
const SegmentSourceOff = "off"
|
||||
|
||||
// SegmentSourceOptions 是设置页可选的源标识。
|
||||
var SegmentSourceOptions = []string{SegmentSourceOff, IntroDBSource, IntroDBAppSource, TODbSource}
|
||||
|
||||
// NewMediaSegmentService is the constructor.
|
||||
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
|
||||
return &MediaSegmentService{
|
||||
@@ -81,14 +113,98 @@ func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentServ
|
||||
return s
|
||||
}
|
||||
|
||||
// SetIntroDB wires the provider. Without it the service only reads cached rows.
|
||||
// SetIntroDB wires the TheIntroDB provider and registers it as a source.
|
||||
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.introdb = p
|
||||
s.registerProvider(p)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetIntroDBApp wires the IntroDB (introdb.app) provider.
|
||||
func (s *MediaSegmentService) SetIntroDBApp(p *IntroDBAppService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.registerProvider(p)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetTODb wires the TODb provider.
|
||||
func (s *MediaSegmentService) SetTODb(p *TODbService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.registerProvider(p)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// SetIMDbResolver wires the TMDb-backed IMDb id resolver used by sources that
|
||||
// only address media by IMDb id.
|
||||
func (s *MediaSegmentService) SetIMDbResolver(r IMDbIDResolver) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.imdb = r
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// registerProvider adds a provider once, keeping registration order as the
|
||||
// fallback order.
|
||||
func (s *MediaSegmentService) registerProvider(p SegmentProvider) {
|
||||
if s == nil || p == nil {
|
||||
return
|
||||
}
|
||||
for _, existing := range s.providers {
|
||||
if existing.Source() == p.Source() {
|
||||
return
|
||||
}
|
||||
}
|
||||
s.providers = append(s.providers, p)
|
||||
}
|
||||
|
||||
// providerBySource returns the registered provider with that source tag.
|
||||
func (s *MediaSegmentService) providerBySource(source string) SegmentProvider {
|
||||
for _, p := range s.providers {
|
||||
if p.Source() == source {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// activeProvider 选出本次查询要用的源。
|
||||
//
|
||||
// 设置项没配置时沿用历史行为(TheIntroDB),避免升级后功能凭空消失;
|
||||
// 显式配置成 off 才真正停用外部查询。
|
||||
func (s *MediaSegmentService) activeProvider(ctx context.Context) SegmentProvider {
|
||||
if s == nil || len(s.providers) == 0 {
|
||||
return nil
|
||||
}
|
||||
source := s.configuredSource(ctx)
|
||||
if source == "" {
|
||||
return s.providerBySource(IntroDBSource)
|
||||
}
|
||||
if source == SegmentSourceOff {
|
||||
return nil
|
||||
}
|
||||
if p := s.providerBySource(source); p != nil {
|
||||
return p
|
||||
}
|
||||
// 配了一个不认识的源(多半是降级回滚后的残留设置):退回默认源,
|
||||
// 让功能继续可用,而不是静默失效。
|
||||
return s.providerBySource(IntroDBSource)
|
||||
}
|
||||
|
||||
// configuredSource reads the operator's source selection.
|
||||
func (s *MediaSegmentService) configuredSource(ctx context.Context) string {
|
||||
if s == nil || s.repo == nil || s.repo.Setting == nil {
|
||||
return ""
|
||||
}
|
||||
v, err := s.repo.Setting.Get(ctx, SegmentSourceSettingKey)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(v))
|
||||
}
|
||||
|
||||
// SetProbe wires the async media probe (duration backfill for STRM). Without it
|
||||
// open-ended credits still work once duration is known from elsewhere.
|
||||
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
|
||||
@@ -114,7 +230,7 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi
|
||||
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
|
||||
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
|
||||
defer s.ensureMediaProbe(ctx, m)
|
||||
return s.introDBSegments(ctx, m)
|
||||
return s.communitySegments(ctx, m)
|
||||
}
|
||||
|
||||
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
|
||||
@@ -147,10 +263,15 @@ func mediaProbeSettled(row *model.MediaProbe) bool {
|
||||
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
|
||||
}
|
||||
|
||||
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
||||
// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。
|
||||
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
|
||||
// communitySegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
||||
// 返回值是按来源优先级合并后的结果(manual > 外部源 > propagated)。
|
||||
func (s *MediaSegmentService) communitySegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
provider := s.activeProvider(ctx)
|
||||
// 没选任何外部源(off):只读本地已有数据,绝不发起外网请求。
|
||||
if provider == nil {
|
||||
return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID)
|
||||
}
|
||||
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, provider.Source())
|
||||
if err != nil {
|
||||
// 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。
|
||||
if ctx.Err() != nil {
|
||||
@@ -159,7 +280,7 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi
|
||||
return nil, err
|
||||
}
|
||||
if ledger == nil || !ledgerFresh(ledger) {
|
||||
if _, _, err := s.refresh(ctx, m); err != nil {
|
||||
if _, _, err := s.refreshWithProvider(ctx, m, provider); err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
}
|
||||
@@ -178,13 +299,22 @@ func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID stri
|
||||
}
|
||||
|
||||
// segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。
|
||||
//
|
||||
// 顺序体现用户意图的强度:
|
||||
//
|
||||
// manual 用户在当前集显式标记
|
||||
// propagated 用户在同季其它集标记后继承来的值(仍然是人给的,优先于社区库)
|
||||
// 外部社区库 自动抓取,最容易被覆盖
|
||||
//
|
||||
// 外部社区库之间刻意同级:用户一次只会启用其中一个,历史数据可能来自另一个,
|
||||
// 同级时按区间起点排序展示即可,不必让旧源压住新源。
|
||||
func segmentSourcePriority(source string) int {
|
||||
switch source {
|
||||
case SegmentSourceManual:
|
||||
return 3
|
||||
case IntroDBSource:
|
||||
return 2
|
||||
case SegmentSourcePropagated:
|
||||
return 2
|
||||
case IntroDBSource, IntroDBAppSource, TODbSource:
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
@@ -224,16 +354,17 @@ func (s *MediaSegmentService) debug(message, mediaID string, err error) {
|
||||
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
|
||||
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
|
||||
// refreshWithProvider 向指定来源查询并落库,返回 (rows, 是否真的发起过查询, error)。
|
||||
//
|
||||
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
|
||||
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
|
||||
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
|
||||
if s.introdb == nil {
|
||||
// 剧集也还没关联到 Series),或者该源需要的 id 类型拿不到(IntroDB 要 IMDb),
|
||||
// 此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
|
||||
func (s *MediaSegmentService) refreshWithProvider(ctx context.Context, m *model.Media, provider SegmentProvider) ([]model.MediaSegment, bool, error) {
|
||||
if s == nil || provider == nil || m == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tmdbID, season, episode := s.queryIDs(ctx, m)
|
||||
if tmdbID <= 0 {
|
||||
query, ok := s.segmentQuery(ctx, m, provider)
|
||||
if !ok {
|
||||
return nil, false, nil
|
||||
}
|
||||
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
|
||||
@@ -256,10 +387,11 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
||||
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
|
||||
defer cancel()
|
||||
|
||||
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
|
||||
spans, err := provider.FetchSegments(fetchCtx, query)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
source := provider.Source()
|
||||
rows := make([]model.MediaSegment, 0, len(spans))
|
||||
for _, span := range spans {
|
||||
rows = append(rows, model.MediaSegment{
|
||||
@@ -268,15 +400,15 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
||||
Kind: span.Kind,
|
||||
StartMs: span.StartMs,
|
||||
EndMs: span.EndMs,
|
||||
Source: IntroDBSource,
|
||||
Source: source,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
|
||||
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, source, rows); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
|
||||
MediaID: m.ID,
|
||||
Source: IntroDBSource,
|
||||
Source: source,
|
||||
FetchedAt: time.Now(),
|
||||
Found: len(rows) > 0,
|
||||
}); err != nil {
|
||||
@@ -286,6 +418,38 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
// refresh 保留旧签名(预热任务与测试使用),走当前选中的源。
|
||||
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
|
||||
return s.refreshWithProvider(ctx, m, s.activeProvider(ctx))
|
||||
}
|
||||
|
||||
// segmentQuery 组装该源需要的身份参数。返回 false 表示这个源缺少必要 ID,
|
||||
// 本次不必发请求。
|
||||
func (s *MediaSegmentService) segmentQuery(ctx context.Context, m *model.Media, provider SegmentProvider) (SegmentQuery, bool) {
|
||||
tmdbID, season, episode := s.queryIDs(ctx, m)
|
||||
isMovie := season <= 0 && episode <= 0
|
||||
q := SegmentQuery{TMDbID: tmdbID, Season: season, Episode: episode, IsMovie: isMovie}
|
||||
|
||||
switch provider.Source() {
|
||||
case IntroDBAppSource:
|
||||
// IntroDB 只认 IMDb id,必须经 TMDb external_ids 解析。
|
||||
if s.imdb == nil || tmdbID <= 0 {
|
||||
return q, false
|
||||
}
|
||||
imdbID, err := s.imdb.ResolveIMDbID(ctx, tmdbID, isMovie)
|
||||
if err != nil || imdbID == "" {
|
||||
// 解析不出 IMDb id 不是错误:这部片可能本来就没有,或 TMDb 不可达。
|
||||
// 返回 false 让本次跳过,但不写负缓存,下次还有机会。
|
||||
s.debug("resolve imdb id failed", m.ID, err)
|
||||
return q, false
|
||||
}
|
||||
q.IMDbID = imdbID
|
||||
return q, true
|
||||
default:
|
||||
return q, tmdbID > 0
|
||||
}
|
||||
}
|
||||
|
||||
// propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有
|
||||
// theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。
|
||||
func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
|
||||
@@ -347,9 +511,131 @@ func hasSegmentKind(rows []model.MediaSegment, kind string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
|
||||
// ManualMarkRequest 是「标记片头/片尾」的入参。
|
||||
//
|
||||
// PositionMs 是用户点击时所在的播放位置:标记片头时它是片头结束点,
|
||||
// 标记片尾时它是片尾开始点。
|
||||
type ManualMarkRequest struct {
|
||||
Kind string
|
||||
PositionMs int64
|
||||
// Clear 为 true 表示清除该类型的标记,PositionMs 被忽略。
|
||||
Clear bool
|
||||
}
|
||||
|
||||
// ErrInvalidManualMark 表示请求本身不合法(类型的取值或位置越界)。
|
||||
var ErrInvalidManualMark = errors.New("invalid manual skip mark")
|
||||
|
||||
// SetManualMark 写入或清除一条手动标记,并按季传播片头/片尾。
|
||||
//
|
||||
// 手动标记的优先级最高(见 segmentSourcePriority),所以用户标过一次之后,
|
||||
// 外部源即使给出不同位置也不会覆盖它。清除时只删 manual 来源的行,外部数据
|
||||
// 会自然回落到优先队列里。
|
||||
func (s *MediaSegmentService) SetManualMark(ctx context.Context, m *model.Media, req ManualMarkRequest) error {
|
||||
if s == nil || s.repo == nil || m == nil || m.ID == "" {
|
||||
return ErrInvalidManualMark
|
||||
}
|
||||
kind := normalizeManualMarkKind(req.Kind)
|
||||
if kind == "" {
|
||||
return ErrInvalidManualMark
|
||||
}
|
||||
if !req.Clear && req.PositionMs < 0 {
|
||||
return ErrInvalidManualMark
|
||||
}
|
||||
rows := []model.MediaSegment{}
|
||||
if !req.Clear {
|
||||
if req.PositionMs <= 0 {
|
||||
// 位置 0 的标记没有意义:片头在 0 秒结束等于没有片头。
|
||||
return ErrInvalidManualMark
|
||||
}
|
||||
rows = append(rows, model.MediaSegment{
|
||||
MediaID: m.ID,
|
||||
SeriesID: m.SeriesID,
|
||||
Kind: kind,
|
||||
StartMs: manualMarkStart(kind),
|
||||
EndMs: req.PositionMs,
|
||||
Source: SegmentSourceManual,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, m.ID, SegmentSourceManual, kind, rows); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
s.propagateManualMarkToSeason(ctx, m, rows)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeManualMarkKind 把外部传入的类型收敛到库内 kind。
|
||||
// 只接受片头与片尾:回顾/预告没有对应的用户操作入口。
|
||||
func normalizeManualMarkKind(kind string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(kind)) {
|
||||
case model.SegmentKindIntro:
|
||||
return model.SegmentKindIntro
|
||||
case model.SegmentKindCredits:
|
||||
return model.SegmentKindCredits
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// manualMarkStart 给出该类型区间的起点:片头从 0 开始,片尾的开始点就是用户
|
||||
// 标记的位置(所以这里填 0,真正的起点在 EndMs 里由调用方传入)。
|
||||
func manualMarkStart(kind string) int64 { return 0 }
|
||||
|
||||
// propagateManualMarkToSeason 把手动标记复制到同季还没有「显式手动标记」的兄弟集。
|
||||
//
|
||||
// 同一季的 OP/ED 通常一致,标一集即可覆盖整季——这是报告里最实用的行为。
|
||||
// 关键约束:传播值必须写成 SegmentSourcePropagated 而不是 manual,这样才能区分
|
||||
// 「用户显式标过的集」与「继承来的值」。否则先标 A 集会让 B 集看起来也是手动标
|
||||
// 过的,之后用户再标 B 集时反而无法覆盖它(或反过来,B 集的传播值挡住 A 集的
|
||||
// 新标记),两种顺序都会得出错误结果。
|
||||
func (s *MediaSegmentService) propagateManualMarkToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) {
|
||||
if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 || len(rows) == 0 {
|
||||
return
|
||||
}
|
||||
siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m)
|
||||
if err != nil {
|
||||
s.debug("list season siblings for manual mark failed", m.ID, err)
|
||||
return
|
||||
}
|
||||
for i := range siblings {
|
||||
sib := &siblings[i]
|
||||
// 用户显式标过的集保持原样;继承值可以被新的显式标记覆盖。
|
||||
explicit, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, SegmentSourceManual)
|
||||
if err != nil {
|
||||
s.debug("list sibling manual segments failed", sib.ID, err)
|
||||
continue
|
||||
}
|
||||
if hasSegmentKind(explicit, rows[0].Kind) {
|
||||
continue
|
||||
}
|
||||
copied := make([]model.MediaSegment, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
copied = append(copied, model.MediaSegment{
|
||||
MediaID: sib.ID,
|
||||
SeriesID: sib.SeriesID,
|
||||
Kind: row.Kind,
|
||||
StartMs: row.StartMs,
|
||||
EndMs: row.EndMs,
|
||||
Source: SegmentSourcePropagated,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, sib.ID, SegmentSourcePropagated, rows[0].Kind, copied); err != nil {
|
||||
s.debug("propagate manual mark failed", sib.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prewarm 批量补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。
|
||||
//
|
||||
// 只预热当前启用的源:用户把源切成 off 时不该继续打外网,切换源之后旧源的
|
||||
// 账本也不再需要刷新。
|
||||
func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) {
|
||||
if s == nil || s.repo == nil || s.introdb == nil {
|
||||
if s == nil || s.repo == nil || len(s.providers) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
provider := s.activeProvider(ctx)
|
||||
if provider == nil {
|
||||
return 0, nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
@@ -360,7 +646,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
|
||||
now = s.now()
|
||||
}
|
||||
candidates, err := s.repo.MediaSegment.ListPrewarmCandidates(
|
||||
ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
|
||||
ctx, provider.Source(), now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit,
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -371,7 +657,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro
|
||||
return attempted, err
|
||||
}
|
||||
m := &candidates[i]
|
||||
_, did, err := s.refresh(ctx, m)
|
||||
_, did, err := s.refreshWithProvider(ctx, m, provider)
|
||||
if err != nil {
|
||||
logIntroDBFailure(s.log, m.TMDbID, err)
|
||||
}
|
||||
|
||||
@@ -385,7 +385,9 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) {
|
||||
// 优先级体现「用户意图强度」:manual > propagated > 社区库。
|
||||
// 传播值来自用户在同季其它集的手动标记,是人给的,必须压过自动抓取的数据。
|
||||
func TestPreferSegmentsBySourcePrefersManualThenPropagatedThenIntroDB(t *testing.T) {
|
||||
rows := []model.MediaSegment{
|
||||
{Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated},
|
||||
{Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource},
|
||||
@@ -395,13 +397,14 @@ func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing
|
||||
}
|
||||
got := preferSegmentsBySource(rows)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %#v, want manual intro + introdb credits", got)
|
||||
t.Fatalf("got %#v, want manual intro + propagated credits", got)
|
||||
}
|
||||
if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 {
|
||||
t.Fatalf("intro = %#v, want manual", got[0])
|
||||
}
|
||||
if got[1].Source != IntroDBSource || got[1].StartMs != 2000 {
|
||||
t.Fatalf("credits = %#v, want theintrodb", got[1])
|
||||
// 片尾没有 manual,propagated 应当压过社区库。
|
||||
if got[1].Source != SegmentSourcePropagated || got[1].StartMs != 1000 {
|
||||
t.Fatalf("credits = %#v, want propagated", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package service — 片头/片尾片段的外部提供方抽象。
|
||||
//
|
||||
// 目前有三个社区库可用,它们对「媒体身份」的要求各不相同:
|
||||
//
|
||||
// TheIntroDB TMDb id(服务端自己映射到 TVDB)
|
||||
// IntroDB 只有 IMDb id(用 is_movie 区分电影/剧集)
|
||||
// TODb TMDb id + 平台类型(tmdb_id_tv / tmdb_id_movie),秒级时间轴
|
||||
//
|
||||
// 播放链路只关心「一段可跳过的区间」,所以这里把三者收敛成同一个
|
||||
// SegmentProvider 接口和 SegmentQuery 入参,上层按设置项挑选其一。
|
||||
package service
|
||||
|
||||
import "context"
|
||||
|
||||
// SegmentSpan 是一个已解析的可跳过区间,单位为毫秒。
|
||||
//
|
||||
// EndMs == 0 是哨兵值,表示「一直到片尾」:只有片尾(credits/preview)可能
|
||||
// 出现这种区间,由客户端结合媒体总时长补齐。片头/回顾不允许开放区间,
|
||||
// 拿不到结束点就整段丢弃——否则会得到一个覆盖全片的区间。
|
||||
type SegmentSpan struct {
|
||||
Kind string
|
||||
StartMs int64
|
||||
EndMs int64
|
||||
}
|
||||
|
||||
// IntroDBSpan 保留旧名字,避免既有调用方和测试大范围改名。
|
||||
type IntroDBSpan = SegmentSpan
|
||||
|
||||
// SegmentQuery 是一次片段查询的身份参数。不同提供方按需取用其中一部分。
|
||||
type SegmentQuery struct {
|
||||
TMDbID int
|
||||
IMDbID string
|
||||
Season int
|
||||
Episode int
|
||||
// IsMovie 为 true 时 Season/Episode 无意义。
|
||||
IsMovie bool
|
||||
}
|
||||
|
||||
// SegmentProvider 是一个外部片段数据源。
|
||||
type SegmentProvider interface {
|
||||
// Source 返回写入 media_segments.source 的标识,也是缓存账本的键。
|
||||
Source() string
|
||||
// FetchSegments 返回该媒体已知的可跳过区间。查不到不是错误(返回空切片),
|
||||
// 只有请求本身失败才返回 error——调用方据此决定是否保留旧缓存。
|
||||
FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error)
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)对「没有数值」有两种
|
||||
// 写法,含义完全不同:end_ms: null = 一直到片尾;end_ms: 0 = 这一段不存在。
|
||||
// 实测约三分之一的剧集会返回 {"start_ms":null,"end_ms":0} 的 intro 占位。
|
||||
const (
|
||||
// 真实返回:intro 是占位、credits 到片尾。
|
||||
introDBNoSegmentPayload = `{"tvdb_id":73762,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":null,"end_ms":0}],"credits":[{"start_ms":2574000,"end_ms":null}]}`
|
||||
// 整集都是占位:recap/credits 都是 (0,0)。
|
||||
introDBAllPlaceholderPayload = `{"tvdb_id":375921,"type":"tv","season":1,"episode":1,"recap":[{"start_ms":0,"end_ms":0}],"credits":[{"start_ms":0,"end_ms":0}]}`
|
||||
)
|
||||
|
||||
// 把 end_ms: 0 当成「到片尾」会让 intro 变成 [0, 片长],整集都是片头:
|
||||
// 一开播就弹跳过按钮,开了自动跳过直接跳到片尾。
|
||||
func TestParseIntroDBResponseDropsZeroEndPlaceholders(t *testing.T) {
|
||||
spans, err := parseIntroDBResponse([]byte(introDBNoSegmentPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 1 {
|
||||
t.Fatalf("spans = %#v, want only the credits range", spans)
|
||||
}
|
||||
if spans[0].Kind != "credits" || spans[0].StartMs != 2_574_000 || spans[0].EndMs != 0 {
|
||||
t.Fatalf("credits span = %#v, want start 2574000 and open end", spans[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseDropsAllPlaceholderSegments(t *testing.T) {
|
||||
spans, err := parseIntroDBResponse([]byte(introDBAllPlaceholderPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 0 {
|
||||
t.Fatalf("spans = %#v, want none for all-placeholder payload", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// 片头/回顾不允许开放结束点:即使数据源给了 null,也不能补齐成「一直到片尾」。
|
||||
func TestParseIntroDBResponseRejectsOpenEndedIntro(t *testing.T) {
|
||||
body := `{"tvdb_id":1,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":1000,"end_ms":null}]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 0 {
|
||||
t.Fatalf("spans = %#v, want an open-ended intro to be dropped", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// IntroDB 的响应把区间给成对象而不是数组,且片尾字段叫 outro。
|
||||
func TestParseIntroDBAppResponseMapsKinds(t *testing.T) {
|
||||
body := `{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,"season":1,"episode":1,
|
||||
"intro":{"start_ms":437000,"end_ms":531000},
|
||||
"recap":null,
|
||||
"outro":{"start_ms":3431000,"end_ms":3500000},
|
||||
"post_credits":{"start_ms":3600000,"end_ms":3610000}}`
|
||||
spans, err := parseIntroDBAppResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
want := []struct {
|
||||
kind string
|
||||
start int64
|
||||
end int64
|
||||
}{
|
||||
{"intro", 437_000, 531_000},
|
||||
{"credits", 3_431_000, 3_500_000},
|
||||
{"preview", 3_600_000, 3_610_000},
|
||||
}
|
||||
if len(spans) != len(want) {
|
||||
t.Fatalf("spans = %#v, want %d", spans, len(want))
|
||||
}
|
||||
for i, w := range want {
|
||||
if spans[i].Kind != w.kind || spans[i].StartMs != w.start || spans[i].EndMs != w.end {
|
||||
t.Fatalf("span[%d] = %#v, want %s %d-%d", i, spans[i], w.kind, w.start, w.end)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IntroDB 只认 IMDb id:没有 IMDb id 时不能退化成用 TMDb id 拼请求,
|
||||
// 那只会在服务端拿到 400,被误记成「查询失败」而保留旧缓存。
|
||||
func TestIntroDBAppFetchSkipsWithoutIMDbID(t *testing.T) {
|
||||
var calls int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 1396, Season: 1, Episode: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(spans) != 0 || calls != 0 {
|
||||
t.Fatalf("spans = %#v calls = %d, want no request without an imdb id", spans, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBAppFetchSendsIMDbAndSeasonEpisode(t *testing.T) {
|
||||
var gotQuery string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
_, _ = w.Write([]byte(`{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,
|
||||
"intro":{"start_ms":1000,"end_ms":2000}}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{
|
||||
IMDbID: "tt0903747", Season: 2, Episode: 3,
|
||||
}); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
for _, want := range []string{"imdb_id=tt0903747", "season=2", "episode=3"} {
|
||||
if !strings.Contains(gotQuery, want) {
|
||||
t.Fatalf("query = %q, want it to contain %q", gotQuery, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 电影要用 is_movie,且不能带季集参数。
|
||||
func TestIntroDBAppFetchUsesIsMovieForMovies(t *testing.T) {
|
||||
var gotQuery string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotQuery = r.URL.RawQuery
|
||||
_, _ = w.Write([]byte(`{"imdb_id":"tt1375666","is_movie":true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{IMDbID: "tt1375666", IsMovie: true}); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "is_movie=true") {
|
||||
t.Fatalf("query = %q, want is_movie=true", gotQuery)
|
||||
}
|
||||
if strings.Contains(gotQuery, "season=") || strings.Contains(gotQuery, "episode=") {
|
||||
t.Fatalf("query = %q, want no season/episode for a movie", gotQuery)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// stubIMDbResolver 返回固定 IMDb id,用于验证 IntroDB 的 id 解析链路。
|
||||
type stubIMDbResolver struct {
|
||||
id string
|
||||
calls int32
|
||||
}
|
||||
|
||||
func (s *stubIMDbResolver) ResolveIMDbID(_ context.Context, _ int, _ bool) (string, error) {
|
||||
atomic.AddInt32(&s.calls, 1)
|
||||
return s.id, nil
|
||||
}
|
||||
|
||||
func TestSegmentSourceOffSkipsExternalLookup(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, SegmentSourceOff); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &model.Media{Base: model.Base{ID: "mv-off"}, Path: "/movies/off.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(t.Context(), m)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 {
|
||||
t.Fatalf("rows = %#v calls = %d, want no lookup when the source is off", rows, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 默认(未配置设置项)必须保持历史行为:用 TheIntroDB。
|
||||
func TestSegmentSourceDefaultsToTheIntroDB(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
m := &model.Media{Base: model.Base{ID: "mv-default"}, Path: "/movies/default.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(t.Context(), m)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("rows = %#v calls = %d, want the default provider to run", rows, calls)
|
||||
}
|
||||
}
|
||||
|
||||
// 切到 TODb 后,TheIntroDB 不应再被调用,写入的 source 也要跟着变。
|
||||
func TestSegmentSourceSwitchesToTODb(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var introCalls, todbCalls int32
|
||||
introServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&introCalls, 1)
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
}))
|
||||
defer introServer.Close()
|
||||
todbServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&todbCalls, 1)
|
||||
_, _ = w.Write([]byte(`{"items":[{"marker":[{"marker_type":"intro","time_start":5,"time_end":30}]}]}`))
|
||||
}))
|
||||
defer todbServer.Close()
|
||||
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(introServer.URL)).
|
||||
SetTODb(NewTODbService(zap.NewNop()).SetBaseURL(todbServer.URL))
|
||||
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, TODbSource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &model.Media{Base: model.Base{ID: "mv-todb"}, Path: "/movies/todb.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(t.Context(), m)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&introCalls) != 0 {
|
||||
t.Fatalf("theintrodb was called %d times, want 0 after switching source", introCalls)
|
||||
}
|
||||
if atomic.LoadInt32(&todbCalls) != 1 {
|
||||
t.Fatalf("todb calls = %d, want 1", todbCalls)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].Source != TODbSource {
|
||||
t.Fatalf("rows = %#v, want one row tagged %s", rows, TODbSource)
|
||||
}
|
||||
if rows[0].StartMs != 5000 || rows[0].EndMs != 30000 {
|
||||
t.Fatalf("row = %#v, want 5000-30000ms", rows[0])
|
||||
}
|
||||
}
|
||||
|
||||
// 未知的源标识(降级回滚残留)要退回默认源,而不是静默失效。
|
||||
func TestSegmentSourceFallsBackForUnknownValue(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, "no-such-source"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &model.Media{Base: model.Base{ID: "mv-unknown"}, Path: "/movies/u.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := svc.ListForPlayback(t.Context(), m); err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&calls) != 1 {
|
||||
t.Fatalf("calls = %d, want the default provider to still run", calls)
|
||||
}
|
||||
}
|
||||
|
||||
// IntroDB 需要 IMDb id;解析不出来时必须跳过请求,且不能写负缓存
|
||||
// (否则元数据补齐之后也永远不会再查)。
|
||||
func TestSegmentIntroDBSkipsWhenIMDbUnavailable(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
_, _ = w.Write([]byte(`{}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDBApp(NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)).
|
||||
SetIMDbResolver(&stubIMDbResolver{id: ""})
|
||||
if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, IntroDBAppSource); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &model.Media{Base: model.Base{ID: "mv-noimdb"}, Path: "/movies/noimdb.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(t.Context(), m)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 {
|
||||
t.Fatalf("rows = %#v calls = %d, want no request without an imdb id", rows, calls)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(t.Context(), "mv-noimdb", IntroDBAppSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger != nil {
|
||||
t.Fatalf("ledger = %#v, want no negative cache entry", ledger)
|
||||
}
|
||||
}
|
||||
@@ -134,8 +134,14 @@ 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))
|
||||
// 片头/片尾片段:播放时按需向社区库补齐并落库,供下次直接命中。
|
||||
// 三个源都注册,具体用哪个由设置项 segment.source 决定(默认 theintrodb)。
|
||||
b.c.Segments = NewMediaSegmentService(b.log, b.repos).
|
||||
SetIntroDB(NewIntroDBService(b.log)).
|
||||
SetIntroDBApp(NewIntroDBAppService(b.log)).
|
||||
SetTODb(NewTODbService(b.log)).
|
||||
// IntroDB 只认 IMDb id,需要经 TMDb 的 external_ids 解析。
|
||||
SetIMDbResolver(b.c.TMDb)
|
||||
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)
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -133,3 +135,34 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error {
|
||||
}
|
||||
return json.NewDecoder(resp.Body).Decode(out)
|
||||
}
|
||||
|
||||
// ResolveIMDbID 用 TMDb 的 external_ids 端点把 TMDb 条目解析成 IMDb id。
|
||||
//
|
||||
// 这是为只认 IMDb id 的片段源(IntroDB)准备的。结果不做本地缓存:TMDb 的
|
||||
// external_ids 响应很小,而片段查询本身已经有 30 天/24 小时的正负缓存兜底,
|
||||
// 再叠一层只会引入失效问题。
|
||||
//
|
||||
// TMDb 未配置或上游不可达时返回空字符串与 error,调用方据此跳过该源,
|
||||
// 而不是把所有媒体都记成「查不到」。
|
||||
func (t *TMDbProvider) ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error) {
|
||||
if t == nil || t.cfg == nil || tmdbID <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
apiKey := t.resolveAPIKey(ctx)
|
||||
if apiKey == "" {
|
||||
return "", nil
|
||||
}
|
||||
mediaType := "tv"
|
||||
if isMovie {
|
||||
mediaType = "movie"
|
||||
}
|
||||
u := t.resolveBaseURL(ctx) + "/" + mediaType + "/" + fmt.Sprint(tmdbID) +
|
||||
"/external_ids?api_key=" + url.QueryEscape(apiKey)
|
||||
var r struct {
|
||||
IMDbID string `json:"imdb_id"`
|
||||
}
|
||||
if err := t.getJSON(ctx, u, &r); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(r.IMDbID), nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// Package service — TODb (TheOtherDB) segment client.
|
||||
//
|
||||
// TODb 的播放资料库按「片源」组织:同一个 TMDB 条目可能有爱奇艺、腾讯等多个
|
||||
// 版本的 marker,每个版本自带 version_runtime(该片源的时长)。因此它的响应是
|
||||
// 一个 items 数组,而不是单一区间集合。
|
||||
//
|
||||
// 接口是 POST + JSON body(GET 会返回 405),平台用 platform_type 表达:
|
||||
//
|
||||
// tmdb_id_tv + season_number / episode_number
|
||||
// tmdb_id_movie 无季集
|
||||
//
|
||||
// marker 的时间轴是**秒**(time_start / time_end),不是毫秒。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TODbBaseURL 是公开只读端点。
|
||||
const TODbBaseURL = "https://playback.theotherdb.org/api/metadata"
|
||||
|
||||
// TODbSource 写入 media_segments.source。
|
||||
const TODbSource = "todb"
|
||||
|
||||
const (
|
||||
todbTimeout = 10 * time.Second
|
||||
todbMaxBodySize = 1 << 20
|
||||
// todbPageSize 一次取多少条版本记录。社区库大多数条目只有一个片源,
|
||||
// 但同一部剧的各平台版本可能都有人标,取多一点可以避免漏掉匹配时长的那个。
|
||||
todbPageSize = 10
|
||||
)
|
||||
|
||||
// todbRequest 是 POST /api/metadata 的请求体。
|
||||
type todbRequest struct {
|
||||
PlatformType string `json:"platform_type"`
|
||||
PlatformValue string `json:"platform_value"`
|
||||
SeasonNumber int `json:"season_number,omitempty"`
|
||||
EpisodeNumber int `json:"episode_number,omitempty"`
|
||||
Includes []string `json:"includes"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// todbMarker 是单个标记点。time_start/time_end 单位是秒。
|
||||
type todbMarker struct {
|
||||
MarkerType string `json:"marker_type"`
|
||||
TimeStart *float64 `json:"time_start"`
|
||||
TimeEnd *float64 `json:"time_end"`
|
||||
}
|
||||
|
||||
// todbItem 是一个片源版本及其 marker。
|
||||
type todbItem struct {
|
||||
VersionID int `json:"version_id"`
|
||||
VersionName string `json:"version_name"`
|
||||
VersionRuntime *int `json:"version_runtime"`
|
||||
VideoType string `json:"video_type"`
|
||||
Marker []todbMarker `json:"marker"`
|
||||
}
|
||||
|
||||
type todbResponse struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
HasMore bool `json:"has_more"`
|
||||
Items []todbItem `json:"items"`
|
||||
}
|
||||
|
||||
// TODbService 查询 TODb 的片段数据。
|
||||
type TODbService struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
baseURL string
|
||||
pageSize int
|
||||
}
|
||||
|
||||
// NewTODbService is the constructor.
|
||||
func NewTODbService(log *zap.Logger) *TODbService {
|
||||
return &TODbService{
|
||||
log: log,
|
||||
client: NewExternalHTTPClient(todbTimeout),
|
||||
baseURL: TODbBaseURL,
|
||||
pageSize: todbPageSize,
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL overrides the API endpoint (tests, mirrors).
|
||||
func (s *TODbService) SetBaseURL(base string) *TODbService {
|
||||
if s != nil && strings.TrimSpace(base) != "" {
|
||||
s.baseURL = strings.TrimSpace(base)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Source implements SegmentProvider.
|
||||
func (s *TODbService) Source() string { return TODbSource }
|
||||
|
||||
// FetchSegments implements SegmentProvider.
|
||||
//
|
||||
// 返回时会把多个版本的 marker 合并:同 kind 取第一个出现的区间。TODb 的片源
|
||||
// 时长各不相同,严格按 version_runtime 匹配当前媒体需要可靠的总时长,而片段
|
||||
// 查询经常跑在时长还不知道的时候(STRM 媒体),所以这里不做版本筛选——宁可
|
||||
// 给一个近似位置,也好过整段缺失。真正需要精度的场景由 manual 标记覆盖。
|
||||
func (s *TODbService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) {
|
||||
if s == nil || s.client == nil || q.TMDbID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
payload := todbRequest{
|
||||
PlatformValue: fmt.Sprint(q.TMDbID),
|
||||
Includes: []string{"marker"},
|
||||
Page: 1,
|
||||
PageSize: s.pageSize,
|
||||
}
|
||||
if q.IsMovie {
|
||||
payload.PlatformType = "tmdb_id_movie"
|
||||
} else {
|
||||
payload.PlatformType = "tmdb_id_tv"
|
||||
payload.SeasonNumber = q.Season
|
||||
payload.EpisodeNumber = q.Episode
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, nil
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("todb: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, todbMaxBodySize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseTODbResponse(raw)
|
||||
}
|
||||
|
||||
// parseTODbResponse 把各片源的 marker 合并成 spans。
|
||||
//
|
||||
// marker_type 的映射沿用报告里的规则:intro -> intro,credits/post_credits ->
|
||||
// credits(片尾),其余(chapter 等章节标记)忽略——章节标题没有可靠的语义,
|
||||
// 当成片头会让跳过点漂到任意位置。
|
||||
func parseTODbResponse(body []byte) ([]SegmentSpan, error) {
|
||||
var raw todbResponse
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse todb json: %w", err)
|
||||
}
|
||||
seen := make(map[string]bool, 4)
|
||||
spans := make([]SegmentSpan, 0, 4)
|
||||
for _, item := range raw.Items {
|
||||
for _, marker := range item.Marker {
|
||||
kind := todbMarkerKind(marker.MarkerType)
|
||||
if kind == "" || seen[kind] {
|
||||
continue
|
||||
}
|
||||
if marker.TimeStart == nil || marker.TimeEnd == nil {
|
||||
continue
|
||||
}
|
||||
startMs := int64(*marker.TimeStart * 1000)
|
||||
endMs := int64(*marker.TimeEnd * 1000)
|
||||
if startMs < 0 {
|
||||
startMs = 0
|
||||
}
|
||||
// 结束点缺失或非正表示「到片尾」,只有这样才是合法的开放区间;
|
||||
// 与 TheIntroDB 不同,TODb 用 0 而不是 null 表示没标结束点,
|
||||
// 所以这里把 <= start 的片尾当作开放区间。
|
||||
if endMs <= startMs {
|
||||
if kind != "credits" && kind != "preview" {
|
||||
continue
|
||||
}
|
||||
endMs = 0
|
||||
}
|
||||
spans = append(spans, SegmentSpan{Kind: kind, StartMs: startMs, EndMs: endMs})
|
||||
seen[kind] = true
|
||||
}
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
// todbMarkerKind 把 TODb 的 marker_type 映射成库内 kind。
|
||||
func todbMarkerKind(markerType string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(markerType)) {
|
||||
case "intro":
|
||||
return "intro"
|
||||
case "recap":
|
||||
return "recap"
|
||||
case "credits", "post_credits", "outro":
|
||||
return "credits"
|
||||
case "preview":
|
||||
return "preview"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TODb 的接口是 POST + JSON body(GET 返回 405),平台类型用 tmdb_id_tv /
|
||||
// tmdb_id_movie 表达,marker 的时间轴是秒。
|
||||
func TestTODbFetchSendsPostWithTMDBPlatform(t *testing.T) {
|
||||
var body map[string]any
|
||||
var method string
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
method = r.Method
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"page":1,"page_size":10,"has_more":false,"items":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 226045, Season: 1, Episode: 1}); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if method != http.MethodPost {
|
||||
t.Fatalf("method = %s, want POST", method)
|
||||
}
|
||||
if body["platform_type"] != "tmdb_id_tv" || body["platform_value"] != "226045" {
|
||||
t.Fatalf("body = %#v, want tmdb_id_tv/226045", body)
|
||||
}
|
||||
if body["season_number"] != float64(1) || body["episode_number"] != float64(1) {
|
||||
t.Fatalf("body = %#v, want season/episode 1", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTODbFetchUsesMoviePlatform(t *testing.T) {
|
||||
var body map[string]any
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &body)
|
||||
_, _ = w.Write([]byte(`{"items":[]}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 83533, IsMovie: true}); err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if body["platform_type"] != "tmdb_id_movie" {
|
||||
t.Fatalf("platform_type = %v, want tmdb_id_movie", body["platform_type"])
|
||||
}
|
||||
if _, ok := body["season_number"]; ok {
|
||||
t.Fatalf("body = %#v, want no season_number for a movie", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 实测返回:marker 的时间是秒,marker_type 为 intro。
|
||||
func TestParseTODbResponseConvertsSecondsToMilliseconds(t *testing.T) {
|
||||
body := `{"page":1,"page_size":3,"has_more":false,"items":[
|
||||
{"version_id":1192,"version_name":"爱奇艺","version_runtime":1300,"video_type":"tv",
|
||||
"marker":[{"marker_id":1588,"marker_type":"intro","time_start":0,"time_end":27}]}]}`
|
||||
spans, err := parseTODbResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 1 {
|
||||
t.Fatalf("spans = %#v, want 1", spans)
|
||||
}
|
||||
if spans[0].Kind != "intro" || spans[0].StartMs != 0 || spans[0].EndMs != 27_000 {
|
||||
t.Fatalf("span = %#v, want intro 0-27000ms", spans[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTODbResponseMapsMarkerTypes(t *testing.T) {
|
||||
body := `{"items":[{"marker":[
|
||||
{"marker_type":"intro","time_start":10,"time_end":30},
|
||||
{"marker_type":"credits","time_start":100,"time_end":120},
|
||||
{"marker_type":"post_credits","time_start":130,"time_end":140},
|
||||
{"marker_type":"chapter","time_start":5,"time_end":6}
|
||||
]}]}`
|
||||
spans, err := parseTODbResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
// chapter 被忽略;post_credits 与 credits 同为片尾,同 kind 只保留第一个。
|
||||
if len(spans) != 2 {
|
||||
t.Fatalf("spans = %#v, want intro + credits", spans)
|
||||
}
|
||||
if spans[0].Kind != "intro" || spans[1].Kind != "credits" {
|
||||
t.Fatalf("kinds = %s/%s, want intro/credits", spans[0].Kind, spans[1].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
// 片尾的结束点缺失或非正表示「到片尾」,是合法的开放区间;片头不行。
|
||||
func TestParseTODbResponseAllowsOpenEndedCreditsOnly(t *testing.T) {
|
||||
body := `{"items":[{"marker":[
|
||||
{"marker_type":"intro","time_start":10,"time_end":10},
|
||||
{"marker_type":"credits","time_start":100,"time_end":100}
|
||||
]}]}`
|
||||
spans, err := parseTODbResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 1 || spans[0].Kind != "credits" || spans[0].EndMs != 0 {
|
||||
t.Fatalf("spans = %#v, want only an open-ended credits", spans)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user