添加跳过片头片尾功能

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
+151
View File
@@ -0,0 +1,151 @@
// Package handler — Emby / Jellyfin 媒体分段(片头、片尾)兼容接口。
//
// GET /MediaSegments/{itemId}
// GET /Items/{itemId}/MediaSegments
// GET /Users/{userId}/Items/{itemId}/MediaSegments
//
// 契约对齐 Jellyfin 10.10 引入的 Media Segments API(Emby 采用同一形状),
// 也是 TheIntroDB 官方 Jellyfin 插件走的同一条路:
//
// QueryResult<MediaSegmentDto> = {"Items": [...], "TotalRecordCount": N}
// MediaSegmentDto = {"Id", "ItemId", "Type", "StartTicks", "EndTicks"}
//
// 时间是 .NET ticks(1 tick = 100ns,即每秒 10,000,000、每毫秒 10,000);
// Type 是枚举名字符串 Intro / Outro / Recap / Preview / Commercial。
package handler
import (
"context"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
// 第三方客户端会在起播前后同步请求分段,不能被一次外网抓取无限拖住。超时后
// 退回已有缓存(可能为空),请求本身永远不失败。
const embyMediaSegmentsFetchBudget = 5 * time.Second
// 1 秒 = 10,000,000 ticks => 1 毫秒 = 10,000 ticks。
const embyTicksPerMillisecond int64 = 10_000
func embyMediaSegmentsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
// 未知条目、虚拟剧集/季、远程 Emby 挂载、以及当前不可见的内容一律返回
// 空结果而不是 404:客户端会把 404 判成「条目损坏」(同 emby_routes.go
// 里 AdditionalParts 的说明),而「没有可跳过的片段」本来就是个合法状态。
empty := gin.H{"Items": []any{}, "TotalRecordCount": 0}
if svc == nil || svc.Repo == nil || svc.Segments == nil {
c.JSON(http.StatusOK, empty)
return
}
id := c.Param("id")
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
if service.IsEmbyRemoteID(id) {
c.JSON(http.StatusOK, empty)
return
}
m, err := svc.Repo.Media.FindByID(c.Request.Context(), id)
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
c.JSON(http.StatusOK, empty)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), embyMediaSegmentsFetchBudget)
defer cancel()
rows, listErr := svc.Segments.ListForPlayback(ctx, m)
if listErr != nil && svc.Log != nil {
svc.Log.Debug("emby media segments lookup failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
items := embySegmentItems(m, rows, embyRequestedSegmentTypes(c))
c.JSON(http.StatusOK, gin.H{"Items": items, "TotalRecordCount": len(items)})
}
}
// embySegmentItems 把库内片段转换成 MediaSegmentDto 列表。
func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string]bool) []gin.H {
// 末段在库内用 end_ms = 0 表示「一直到片尾」(TheIntroDB 对片尾返回 end_ms: null),
// 这里必须换算成真实结束时间;拿不到时长就丢弃该分段,否则会给出一个零长度区间,
// 客户端要么忽略要么画出一个错误的跳转点。
durationMs := int64(m.DurationSec) * 1000
items := make([]gin.H, 0, len(rows))
for _, row := range rows {
typeName := embySegmentTypeName(row.Kind)
if typeName == "" {
continue
}
if len(want) > 0 && !want[typeName] {
continue
}
endMs := row.EndMs
if endMs <= 0 {
if durationMs <= 0 {
continue
}
endMs = durationMs
}
if endMs <= row.StartMs {
continue
}
items = append(items, gin.H{
"Id": row.ID,
"ItemId": m.ID,
"Type": typeName,
"StartTicks": row.StartMs * embyTicksPerMillisecond,
"EndTicks": endMs * embyTicksPerMillisecond,
})
}
return items
}
// embySegmentTypeName 把库内 kind 映射成 Emby/Jellyfin 的 MediaSegmentType 名字。
// 库内的 credits 取自 TheIntroDB 的字段名,在 Emby 一侧对应 Outro。
func embySegmentTypeName(kind string) string {
switch kind {
case model.SegmentKindIntro:
return "Intro"
case model.SegmentKindRecap:
return "Recap"
case model.SegmentKindCredits:
return "Outro"
case model.SegmentKindPreview:
return "Preview"
default:
return ""
}
}
// embyRequestedSegmentTypes 解析 includeSegmentTypes(Jellyfin 的过滤参数)。
// 支持重复参数与逗号分隔两种写法;返回空集合表示不过滤。
//
// 只认枚举名字符串。数字枚举虽然 ASP.NET 模型绑定也接受,但各家定义的顺序并
// 不一致,猜错会把过滤结果算错;认不出来时按「不过滤」处理,返回的是超集,
// 客户端自己仍会再过滤一次。
func embyRequestedSegmentTypes(c *gin.Context) map[string]bool {
raw := make([]string, 0, 4)
for _, key := range []string{"includeSegmentTypes", "IncludeSegmentTypes", "includesegmenttypes"} {
raw = append(raw, c.QueryArray(key)...)
}
want := make(map[string]bool, len(raw))
for _, value := range raw {
for _, part := range strings.Split(value, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
for _, name := range []string{"Intro", "Outro", "Recap", "Preview", "Commercial"} {
if strings.EqualFold(part, name) {
want[name] = true
}
}
}
}
return want
}
@@ -0,0 +1,217 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
"github.com/truewhile/MeBox/internal/service"
)
type embySegmentsPayload struct {
Items []struct {
ID string `json:"Id"`
ItemID string `json:"ItemId"`
Type string `json:"Type"`
StartTicks int64 `json:"StartTicks"`
EndTicks int64 `json:"EndTicks"`
} `json:"Items"`
TotalRecordCount int `json:"TotalRecordCount"`
}
// 电影:intro 有明确结束点;credits 的 end_ms 为 null(库内落成 0),
// 必须用媒体时长补齐 —— 这是最容易写错的一处。
const embySegmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
const (
embySegmentsMovieDurationSec = 8880
embySegmentsTicksPerSecond = 10_000_000
)
func newEmbySegmentsTestRouter(t *testing.T, durationSec int) (*gin.Engine, string, *int32) {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(model.AllModels()...); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
if err := repos.User.Create(t.Context(), &model.User{
Base: model.Base{ID: "user-1"},
Username: "tester",
PasswordHash: "x",
Role: "admin",
Tier: "plus",
IsActive: true,
}); err != nil {
t.Fatal(err)
}
lib := model.Library{Base: model.Base{ID: "lib-movies"}, Name: "电影", Path: "D:\\media\\movies", Type: "movie", Enabled: true}
if err := repos.Library.Create(t.Context(), &lib); err != nil {
t.Fatal(err)
}
if err := db.Create(&model.Media{
Base: model.Base{ID: "movie-1"},
LibraryID: lib.ID,
Title: "Inception",
Path: "D:\\media\\movies\\Inception.mkv",
DurationSec: durationSec,
TMDbID: 27205,
}).Error; err != nil {
t.Fatal(err)
}
var calls int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(embySegmentsProviderBody))
}))
t.Cleanup(provider.Close)
segments := service.NewMediaSegmentService(zap.NewNop(), repos).
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
const secret = "test-secret"
router := gin.New()
registerEmbyRoutes(router, secret, &service.Container{
Repo: repos,
Emby: service.NewEmbyService(&config.Config{}, zap.NewNop(), repos),
Segments: segments,
Log: zap.NewNop(),
})
return router, secret, &calls
}
func embySegmentsRequest(t *testing.T, router *gin.Engine, secret, path string) embySegmentsPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("X-Emby-Token", signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String())
}
var payload embySegmentsPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("%s decode: %v", path, err)
}
return payload
}
func TestEmbyMediaSegmentsReturnsTicksAndMapsCreditsToOutro(t *testing.T) {
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
if payload.TotalRecordCount != 2 || len(payload.Items) != 2 {
t.Fatalf("segments = %#v (total %d), want 2", payload.Items, payload.TotalRecordCount)
}
intro := payload.Items[0]
if intro.Type != "Intro" || intro.ItemID != "movie-1" || intro.ID == "" {
t.Fatalf("intro segment = %#v", intro)
}
// start_ms null 表示从片头开始。
if intro.StartTicks != 0 || intro.EndTicks != 38*embySegmentsTicksPerSecond {
t.Fatalf("intro ticks = %d..%d, want 0..%d",
intro.StartTicks, intro.EndTicks, 38*embySegmentsTicksPerSecond)
}
// 库内 credits 在 Emby 一侧是 Outro;end_ms = 0 必须按媒体时长补齐,
// 否则客户端会拿到一个零长度区间。
outro := payload.Items[1]
if outro.Type != "Outro" {
t.Fatalf("credits should map to Outro, got %q", outro.Type)
}
if outro.StartTicks != 6480*embySegmentsTicksPerSecond {
t.Fatalf("outro StartTicks = %d, want %d", outro.StartTicks, 6480*embySegmentsTicksPerSecond)
}
if outro.EndTicks != embySegmentsMovieDurationSec*embySegmentsTicksPerSecond {
t.Fatalf("outro EndTicks = %d, want the media duration %d",
outro.EndTicks, embySegmentsMovieDurationSec*embySegmentsTicksPerSecond)
}
}
func TestEmbyMediaSegmentsIsServedFromTheSameCacheAsTheWebPlayer(t *testing.T) {
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
// 多条路径 + 大小写变体都应命中同一份缓存,而不是各自再打一次外网。
for _, path := range []string{
"/MediaSegments/movie-1",
"/mediasegments/movie-1",
"/Items/movie-1/MediaSegments",
"/items/movie-1/mediasegments",
"/Users/user-1/Items/movie-1/MediaSegments",
} {
payload := embySegmentsRequest(t, router, secret, path)
if len(payload.Items) != 2 {
t.Fatalf("%s returned %#v, want 2 segments", path, payload.Items)
}
}
if got := atomic.LoadInt32(calls); got != 1 {
t.Fatalf("provider calls = %d, want 1 (all routes share the cached rows)", got)
}
}
func TestEmbyMediaSegmentsHonoursIncludeSegmentTypes(t *testing.T) {
router, secret, _ := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=Intro")
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
t.Fatalf("filtered segments = %#v (total %d), want only Intro", payload.Items, payload.TotalRecordCount)
}
if payload.Items[0].Type != "Intro" {
t.Fatalf("filtered type = %q, want Intro", payload.Items[0].Type)
}
// 认不出的枚举名按「不过滤」处理:返回超集比返回空集安全。
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1?includeSegmentTypes=NotAType")
if payload.TotalRecordCount != 2 {
t.Fatalf("unknown filter returned %d segments, want the unfiltered set", payload.TotalRecordCount)
}
}
func TestEmbyMediaSegmentsDropsOpenEndedRangeWhenDurationUnknown(t *testing.T) {
// 时长未知(STRM/云盘媒体探测前)时,credits 无法换算成真实结束点,
// 只能丢弃;有明确结束点的 intro 必须保留。
router, secret, _ := newEmbySegmentsTestRouter(t, 0)
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/movie-1")
if payload.TotalRecordCount != 1 || len(payload.Items) != 1 {
t.Fatalf("segments = %#v (total %d), want only the intro", payload.Items, payload.TotalRecordCount)
}
if payload.Items[0].Type != "Intro" {
t.Fatalf("kept segment = %#v, want Intro", payload.Items[0])
}
}
func TestEmbyMediaSegmentsReturnsEmptyInsteadOfNotFound(t *testing.T) {
router, secret, calls := newEmbySegmentsTestRouter(t, embySegmentsMovieDurationSec)
// 未知条目必须 200 + 空数组:客户端会把 404 判成「条目损坏」。
payload := embySegmentsRequest(t, router, secret, "/MediaSegments/does-not-exist")
if payload.Items == nil || len(payload.Items) != 0 || payload.TotalRecordCount != 0 {
t.Fatalf("unknown item payload = %#v", payload)
}
// 远程 Emby 条目同理(本地没有可查询的外部 ID 关联)。
payload = embySegmentsRequest(t, router, secret, "/MediaSegments/embyremote~acct1~item1")
if len(payload.Items) != 0 {
t.Fatalf("remote emby item payload = %#v, want empty", payload.Items)
}
if got := atomic.LoadInt32(calls); got != 0 {
t.Fatalf("provider calls = %d, want 0 for unresolvable items", got)
}
}
+3 -1
View File
@@ -198,7 +198,9 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc)) auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
auth.GET("/Shows/NextUp", embyNextUpHandler(svc)) auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc)) auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
auth.GET("/MediaSegments/:id", embyEmptyItemsHandler(svc)) auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
auth.GET("/Artists", embyEmptyItemsHandler(svc)) auth.GET("/Artists", embyEmptyItemsHandler(svc))
auth.GET("/Persons", embyEmptyItemsHandler(svc)) auth.GET("/Persons", embyEmptyItemsHandler(svc))
auth.GET("/Genres", embyGenresHandler(svc)) auth.GET("/Genres", embyGenresHandler(svc))
+3 -1
View File
@@ -42,7 +42,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc)) auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
auth.GET("/shows/nextup", embyNextUpHandler(svc)) auth.GET("/shows/nextup", embyNextUpHandler(svc))
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc)) auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
auth.GET("/mediasegments/:id", embyEmptyItemsHandler(svc)) auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
auth.GET("/artists", embyEmptyItemsHandler(svc)) auth.GET("/artists", embyEmptyItemsHandler(svc))
auth.GET("/persons", embyEmptyItemsHandler(svc)) auth.GET("/persons", embyEmptyItemsHandler(svc))
auth.GET("/genres", embyGenresHandler(svc)) auth.GET("/genres", embyGenresHandler(svc))
+4
View File
@@ -346,6 +346,9 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
&model.Library{}, &model.Library{},
&model.Media{}, &model.Media{},
&model.PlayProfile{}, &model.PlayProfile{},
&model.Series{},
&model.MediaSegment{},
&model.MediaSegmentFetch{},
); err != nil { ); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -408,6 +411,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
api := router.Group("/api") api := router.Group("/api")
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret)) api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
api.GET("/playback/:id/info", playbackInfoHandler(svc)) api.GET("/playback/:id/info", playbackInfoHandler(svc))
api.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
api.GET("/playback/:id/external-url", externalURLHandler(svc)) api.GET("/playback/:id/external-url", externalURLHandler(svc))
api.GET("/playback/:id/external-players", externalPlayersHandler(svc)) api.GET("/playback/:id/external-players", externalPlayersHandler(svc))
api.GET("/stream/:id", streamHandler(svc)) api.GET("/stream/:id", streamHandler(svc))
+55
View File
@@ -0,0 +1,55 @@
// Package handler — 片头/片尾片段接口。
//
// GET /playback/:id/segments
//
// 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据
// 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 /
// 远程 Emby)无关,独立出来对所有媒体一致。
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/service"
)
// playbackSegmentsHandler returns the skippable ranges for one media item.
// The client calls it after playback has already started, so the provider
// lookup never delays a play.
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
return func(c *gin.Context) {
autoSkip := resolveAutoSkipFlag(c, svc)
segments := []service.SegmentView{}
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 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
if svc.Segments != nil && !service.IsEmbyRemoteID(m.ID) {
rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m)
if listErr != nil && svc.Log != nil {
svc.Log.Debug("list media segments failed",
zap.String("media_id", m.ID), zap.Error(listErr))
}
segments = service.ToSegmentViews(rows)
}
c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip})
}
}
// 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
// default profile), and a PIN-locked profile never silently skips for the user.
func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool {
profile, locked := selectedPlayProfile(c, svc)
if locked || profile == nil {
return false
}
return profile.SkipIntro
}
+134
View File
@@ -0,0 +1,134 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/service"
)
type segmentPayload struct {
Segments []struct {
Kind string `json:"kind"`
StartMs int64 `json:"start_ms"`
EndMs int64 `json:"end_ms"`
} `json:"segments"`
AutoSkip bool `json:"auto_skip"`
}
const segmentsProviderBody = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}],"credits":[{"start_ms":6480000,"end_ms":null}]}`
func TestPlaybackSegmentsReturnsProviderDataAndAutoSkip(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
// 片段数据与播放来源无关,云盘媒体同样适用,只要它能解析出外部 ID。
// 注意列名是 tm_db_id(GORM 对 TMDbID 的默认命名)。
if err := svc.Repo.DB.Model(&model.Media{}).
Where("id = ?", "media-1").Update("tm_db_id", 27205).Error; err != nil {
t.Fatal(err)
}
var calls int32
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
atomic.AddInt32(&calls, 1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(segmentsProviderBody))
}))
defer provider.Close()
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo).
SetIntroDB(service.NewIntroDBService(zap.NewNop()).SetBaseURL(provider.URL))
// 默认档案打开「自动跳过片头」,接口应把开关原样带出来。
if err := svc.Repo.DB.Create(&model.PlayProfile{
Base: model.Base{ID: "profile-1"},
UserID: "user-1",
Name: "主档案",
IsDefault: true,
SkipIntro: true,
}).Error; err != nil {
t.Fatal(err)
}
loginToken := signedTestToken(t, secret)
fetch := func() segmentPayload {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
req.Header.Set("Authorization", "Bearer "+loginToken)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload segmentPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
return payload
}
first := fetch()
if !first.AutoSkip {
t.Fatal("auto_skip should reflect the active profile's skip_intro switch")
}
if len(first.Segments) != 2 {
t.Fatalf("segments = %#v, want 2", first.Segments)
}
// start_ms: null -> 0;end_ms: null -> 0(延续到片尾,由客户端按时长补齐)。
if first.Segments[0].Kind != "intro" || first.Segments[0].StartMs != 0 || first.Segments[0].EndMs != 38_000 {
t.Fatalf("intro segment = %#v", first.Segments[0])
}
if first.Segments[1].Kind != "credits" || first.Segments[1].StartMs != 6_480_000 || first.Segments[1].EndMs != 0 {
t.Fatalf("credits segment = %#v", first.Segments[1])
}
// 第二次播放必须走本地缓存,不再打外网。
if second := fetch(); len(second.Segments) != 2 {
t.Fatalf("second fetch segments = %#v", second.Segments)
}
if got := atomic.LoadInt32(&calls); got != 1 {
t.Fatalf("provider calls = %d, want 1", got)
}
}
func TestPlaybackSegmentsAutoSkipIsFalseWithoutProfile(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/media-1/segments", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var payload segmentPayload
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if payload.AutoSkip {
t.Fatal("auto_skip must default to false")
}
// 即使一条片段都没有,也必须返回空数组而不是 null,前端才能无条件遍历。
if payload.Segments == nil {
t.Fatal("segments must serialise as an empty array, not null")
}
}
func TestPlaybackSegmentsForUnknownMediaReturnsNotFound(t *testing.T) {
router, svc, secret := newPlaybackScopeTestRouter(t)
svc.Segments = service.NewMediaSegmentService(zap.NewNop(), svc.Repo)
req := httptest.NewRequest(http.MethodGet, "http://nas.local/api/playback/does-not-exist/segments", nil)
req.Header.Set("Authorization", "Bearer "+signedTestToken(t, secret))
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d body=%s, want 404", w.Code, w.Body.String())
}
}
@@ -73,6 +73,7 @@ func registerAuthedFavoriteAndMediaActionRoutes(authed *gin.RouterGroup, svc *se
func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) { func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Container) {
authed.GET("/playback/:id/info", playbackInfoHandler(svc)) authed.GET("/playback/:id/info", playbackInfoHandler(svc))
authed.GET("/playback/:id/resume", playbackResumeHandler(svc)) authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
authed.POST("/playback/:id/progress", playbackProgressHandler(svc)) authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc)) authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
authed.GET("/playback/:id/external-url", externalURLHandler(svc)) authed.GET("/playback/:id/external-url", externalURLHandler(svc))
@@ -36,6 +36,7 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
"GET /api/storage", "GET /api/storage",
"GET /api/watch-history", "GET /api/watch-history",
"GET /api/playback/:id/info", "GET /api/playback/:id/info",
"GET /api/playback/:id/segments",
} { } {
if !routes[want] { if !routes[want] {
t.Fatalf("%s route is not registered", want) t.Fatalf("%s route is not registered", want)
+39
View File
@@ -0,0 +1,39 @@
package model
import "time"
// 片段类型与 TheIntroDB 的返回字段一一对应。客户端按 kind 决定按钮文案
// (片头 / 回顾 / 片尾 / 预告),不依赖具体来源。
const (
SegmentKindIntro = "intro"
SegmentKindRecap = "recap"
SegmentKindCredits = "credits"
SegmentKindPreview = "preview"
)
// MediaSegment 是媒体源时间轴上一个可被跳过的区间(片头 / 回顾 / 片尾 / 预告)。
// 提供方(当前为 TheIntroDB)填充,播放器消费后向用户提供「跳过片头」。
type MediaSegment struct {
Base
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_media_segment" json:"media_id"`
SeriesID string `gorm:"index;size:128" json:"series_id,omitempty"`
Kind string `gorm:"size:16;not null;uniqueIndex:uniq_media_segment" json:"kind"`
// StartMs/EndMs 是媒体源时间轴上的毫秒绝对值。EndMs 为 0 表示区间一直延续到
// 片尾(TheIntroDB 对末段返回 end_ms: null),由客户端结合媒体总时长补齐。
StartMs int64 `gorm:"not null;default:0;uniqueIndex:uniq_media_segment" json:"start_ms"`
EndMs int64 `gorm:"not null;default:0" json:"end_ms"`
// Source 记录数据来源,让同一媒体上多来源共存、以及将来的人工覆盖成为可能。
// 它必须参与唯一索引:否则「外部数据」与「人工修正」给出同一区间时会撞索引。
Source string `gorm:"size:32;not null;default:'';uniqueIndex:uniq_media_segment" json:"source,omitempty"`
}
// MediaSegmentFetch 记录「某媒体的片段是否已向某来源查询过」。
// 单独建表是为了能缓存「查不到」这个结果:没有负缓存的话,每次播放一部社区库里
// 还没有数据的影片都会重新打一次外网。
type MediaSegmentFetch struct {
Base
MediaID string `gorm:"index;size:128;not null;uniqueIndex:uniq_media_segment_fetch" json:"media_id"`
Source string `gorm:"size:32;not null;uniqueIndex:uniq_media_segment_fetch" json:"source"`
FetchedAt time.Time `json:"fetched_at"`
Found bool `json:"found"`
}
+2
View File
@@ -38,6 +38,8 @@ func AllModels() []interface{} {
&Series{}, &Series{},
&Media{}, &Media{},
&PlaybackHistory{}, &PlaybackHistory{},
&MediaSegment{},
&MediaSegmentFetch{},
&Favorite{}, &Favorite{},
&Playlist{}, &Playlist{},
&PlaylistItem{}, &PlaylistItem{},
@@ -0,0 +1,74 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/truewhile/MeBox/internal/model"
)
// MediaSegmentRepository persists intro/recap/credits/preview ranges.
type MediaSegmentRepository struct{ db *gorm.DB }
// ListByMedia returns every stored segment for a media item, ordered by start.
func (r *MediaSegmentRepository) ListByMedia(ctx context.Context, mediaID string) ([]model.MediaSegment, error) {
rows := make([]model.MediaSegment, 0, 4)
err := r.db.WithContext(ctx).
Where("media_id = ?", mediaID).
Order("start_ms asc").
Find(&rows).Error
return rows, err
}
// ReplaceForMedia swaps the segments contributed by one source in a single
// transaction, so a provider refresh can never leave a half-updated set.
//
// Rows are hard-deleted rather than soft-deleted: the unique index on
// (media_id, kind, start_ms) would otherwise collide with the tombstoned rows
// on the next insert.
func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, source 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 = ?", mediaID, source).
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
err := r.db.WithContext(ctx).
Where("media_id = ? AND source = ?", mediaID, source).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// UpsertFetch records the outcome of a provider lookup. The `deleted_at: nil`
// assignment revives a previously deleted row instead of failing on the unique
// index, mirroring the playback history upsert.
func (r *MediaSegmentRepository) UpsertFetch(ctx context.Context, row *model.MediaSegmentFetch) error {
onConflict := clause.OnConflict{
Columns: []clause.Column{{Name: "media_id"}, {Name: "source"}},
DoUpdates: clause.Assignments(map[string]any{
"fetched_at": row.FetchedAt,
"found": row.Found,
"deleted_at": nil,
}),
}
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
}
@@ -0,0 +1,154 @@
package repository
import (
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"github.com/truewhile/MeBox/internal/database"
"github.com/truewhile/MeBox/internal/model"
)
func newSegmentTestRepos(t *testing.T) *Container {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := database.AutoMigrate(db); err != nil {
t.Fatalf("migrate: %v", err)
}
return New(db)
}
func TestReplaceForMediaIsIdempotentAcrossRefreshes(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
rows := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 228_664, EndMs: 246_143, Source: "theintrodb"},
{MediaID: "m-1", Kind: model.SegmentKindCredits, StartMs: 3_431_000, EndMs: 0, Source: "theintrodb"},
}
// 重复刷新不能因为 (media_id, kind, start_ms, source) 唯一索引而失败,也不能累积重复行。
for i := 0; i < 2; i++ {
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", rows); err != nil {
t.Fatalf("replace #%d: %v", i+1, err)
}
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2 after two refreshes of the same source", len(got))
}
// 替换只影响同一来源:另一个来源的数据必须保留。
other := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: "manual"},
}
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "manual", other); err != nil {
t.Fatal(err)
}
got, err = repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("rows = %d, want 3 (2 theintrodb + 1 manual)", len(got))
}
if got[0].Source != "manual" || got[0].StartMs != 10 {
t.Fatalf("rows should be ordered by start_ms, got first = %#v", got[0])
}
}
func TestReplaceForMediaAllowsSameRangeFromDifferentSources(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
span := model.MediaSegment{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 1_000, EndMs: 2_000}
provider := span
provider.Source = "theintrodb"
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", []model.MediaSegment{provider}); err != nil {
t.Fatal(err)
}
// 人工修正给出完全相同的区间:唯一索引含 source,两个来源必须能共存。
manual := span
manual.Source = "manual"
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "manual", []model.MediaSegment{manual}); err != nil {
t.Fatalf("same range from another source: %v", err)
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("rows = %d, want 2 (one per source)", len(got))
}
}
func TestReplaceForMediaClearsRowsWhenLookupReturnsNothing(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
rows := []model.MediaSegment{
{MediaID: "m-1", Kind: model.SegmentKindIntro, StartMs: 1_000, EndMs: 2_000, Source: "theintrodb"},
}
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", rows); err != nil {
t.Fatal(err)
}
// 提供方后来把这段数据删掉了,本地必须跟着清空,否则会一直跳一个不存在的片头。
if err := repos.MediaSegment.ReplaceForMedia(ctx, "m-1", "theintrodb", nil); err != nil {
t.Fatal(err)
}
got, err := repos.MediaSegment.ListByMedia(ctx, "m-1")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("rows = %d, want 0 after an empty refresh", len(got))
}
}
func TestUpsertFetchKeepsOneRowPerMediaAndSource(t *testing.T) {
repos := newSegmentTestRepos(t)
ctx := t.Context()
now := time.Now()
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "m-1", Source: "theintrodb", FetchedAt: now, Found: false,
}); err != nil {
t.Fatalf("first upsert: %v", err)
}
got, err := repos.MediaSegment.GetFetch(ctx, "m-1", "theintrodb")
if err != nil {
t.Fatal(err)
}
if got == nil || got.Found {
t.Fatalf("first lookup should be recorded as a miss, got %#v", got)
}
later := now.Add(time.Hour)
if err := repos.MediaSegment.UpsertFetch(ctx, &model.MediaSegmentFetch{
MediaID: "m-1", Source: "theintrodb", FetchedAt: later, Found: true,
}); err != nil {
t.Fatalf("second upsert: %v", err)
}
var count int64
if err := repos.DB.Model(&model.MediaSegmentFetch{}).
Where("media_id = ? AND source = ?", "m-1", "theintrodb").Count(&count).Error; err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("fetch ledger rows = %d, want 1", count)
}
got, err = repos.MediaSegment.GetFetch(ctx, "m-1", "theintrodb")
if err != nil {
t.Fatal(err)
}
if got == nil || !got.Found {
t.Fatalf("ledger should be updated in place, got %#v", got)
}
}
+2
View File
@@ -15,6 +15,7 @@ type Container struct {
Media *MediaRepository Media *MediaRepository
Series *SeriesRepository Series *SeriesRepository
History *HistoryRepository History *HistoryRepository
MediaSegment *MediaSegmentRepository
Favorite *FavoriteRepository Favorite *FavoriteRepository
Playlist *PlaylistRepository Playlist *PlaylistRepository
Setting *SettingRepository Setting *SettingRepository
@@ -45,6 +46,7 @@ func New(db *gorm.DB) *Container {
Media: &MediaRepository{db: db}, Media: &MediaRepository{db: db},
Series: &SeriesRepository{db: db}, Series: &SeriesRepository{db: db},
History: &HistoryRepository{db: db}, History: &HistoryRepository{db: db},
MediaSegment: &MediaSegmentRepository{db: db},
Favorite: &FavoriteRepository{db: db}, Favorite: &FavoriteRepository{db: db},
Playlist: &PlaylistRepository{db: db}, Playlist: &PlaylistRepository{db: db},
Setting: &SettingRepository{db: db}, Setting: &SettingRepository{db: db},
+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 Fanart *FanartProvider
Scraper *ScraperService Scraper *ScraperService
Playback *PlaybackService Playback *PlaybackService
Segments *MediaSegmentService
ImageProxy *ImageProxy ImageProxy *ImageProxy
Watcher *WatcherService Watcher *WatcherService
Subtitle *SubtitleService 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.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.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote) 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.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
b.c.Profile = NewProfileService(b.log, b.repos) b.c.Profile = NewProfileService(b.log, b.repos)
b.c.Audit = NewAuditService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos)
+8 -1
View File
@@ -1,5 +1,5 @@
import { api } from './client' import { api } from './client'
import type { Media, Playlist } from '../types' import type { Media, PlaybackSegmentsResponse, Playlist } from '../types'
// History rows arrive joined with their Media row; the backend returns null // History rows arrive joined with their Media row; the backend returns null
// for orphaned rows whose media has been removed. // for orphaned rows whose media has been removed.
@@ -47,6 +47,13 @@ export const playbackAPI = {
.get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`) .get<{ position_ms: number; duration_ms: number; completed: boolean }>(`/playback/${mediaId}/resume`)
.then((r) => r.data), .then((r) => r.data),
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
// 因此绝不能让它挡在起播路径上。
segments: (mediaId: string) =>
api
.get<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`)
.then((r) => r.data),
recordProgress: (payload: PlaybackProgressRequest) => recordProgress: (payload: PlaybackProgressRequest) =>
api.post('/history', payload).then((r) => r.data), api.post('/history', payload).then((r) => r.data),
+200 -1
View File
@@ -17,10 +17,11 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
import { systemAPI } from '../api/system' import { systemAPI } from '../api/system'
import { profileAPI } from '../api/profile' import { profileAPI } from '../api/profile'
import { useAuthStore } from '../stores/auth' import { useAuthStore } from '../stores/auth'
import type { Media, PlaybackInfo, PlaybackQuality } from '../types' import type { Media, PlaybackInfo, PlaybackQuality, PlaybackSegment, PlaybackSegmentKind } from '../types'
import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries' import { getSeriesKey, seriesTitleFromPath } from '../utils/groupSeries'
import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion' import { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
import { normalizePlaybackRate } from '../utils/playbackRate' import { normalizePlaybackRate } from '../utils/playbackRate'
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
import { isRemoteEmbyID } from '../utils/remoteEmby' import { isRemoteEmbyID } from '../utils/remoteEmby'
import { import {
normalizeSubtitleChineseMode, normalizeSubtitleChineseMode,
@@ -71,6 +72,9 @@ type PlaybackProgressSession = {
sequence: number sequence: number
} }
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
const SKIP_NOTICE_MS = 6000
function normalizePlayerVolume(value: unknown): number { function normalizePlayerVolume(value: unknown): number {
const parsed = Number(value) const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1 if (!Number.isFinite(parsed)) return 1
@@ -697,6 +701,63 @@ export function PlayerPage() {
cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5) cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5)
}, [playbackInfo]) }, [playbackInfo])
// ── 片头/片尾跳过 ──────────────────────────────────────────────────────────
// 原始片段(服务端单位)与当前生效档案的「自动跳过片头」开关。
const [rawSkipSegments, setRawSkipSegments] = useState<PlaybackSegment[]>([])
const [autoSkipIntro, setAutoSkipIntro] = useState(false)
// 当前落进的跳过提示;不在任何区间时为 null。
const [activeSkip, setActiveSkip] = useState<SkipPrompt | null>(null)
// 自动跳过后的撤销提示。
const [skipNotice, setSkipNotice] = useState<{
text: string
startSec: number
kind: PlaybackSegmentKind
} | null>(null)
// 用户已经处理过的区间类型:本次播放内不再重复提示。
const [dismissedSkipKinds, setDismissedSkipKinds] = useState<PlaybackSegmentKind[]>([])
// 用户主动跳进过片头/片尾区间(多半是想重看):本次播放不再自动跳过该类型,
// 但按钮保留,想跳随时可以点。
const [autoSuppressedKinds, setAutoSuppressedKinds] = useState<PlaybackSegmentKind[]>([])
const skipNoticeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
// 标记「这次 seeked 是我们自己跳的」,避免把自己的跳转当成用户手动跳转。
const selfSkipSeekRef = useRef(false)
// end_ms 为 0 的区间要用媒体总时长补齐,而时长可能晚于片段到达(STRM/HLS 起播
// 后才回填 duration_sec),所以派生放在这里,时长更新后区间会自动重算。
const skipSegments = useMemo(
() => toSkipSegments(rawSkipSegments, media?.duration_sec || 0),
[rawSkipSegments, media?.duration_sec],
)
useEffect(() => {
if (!mediaId) return
let cancelled = false
setRawSkipSegments([])
setAutoSkipIntro(false)
setActiveSkip(null)
setSkipNotice(null)
setDismissedSkipKinds([])
setAutoSuppressedKinds([])
// 片段数据与播放来源无关,播放开始后异步补抓即可,绝不挡在起播路径上。
playbackAPI
.segments(mediaId)
.then((res) => {
if (cancelled) return
setAutoSkipIntro(Boolean(res.auto_skip))
setRawSkipSegments(res.segments ?? [])
})
.catch(() => undefined)
return () => {
cancelled = true
}
}, [mediaId])
useEffect(() => {
return () => {
if (skipNoticeTimerRef.current) clearTimeout(skipNoticeTimerRef.current)
}
}, [])
const switchToLocalHLS = useCallback( const switchToLocalHLS = useCallback(
(position = 0) => { (position = 0) => {
setHlsSource('local') setHlsSource('local')
@@ -1217,6 +1278,58 @@ export function PlayerPage() {
return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : '' return nextEpisode ? formatEpisodeDisplay(nextEpisode, playlistEpisodes) : ''
}, [nextEpisode, playlistEpisodes]) }, [nextEpisode, playlistEpisodes])
// ── 片头/片尾跳过:位置判定 ────────────────────────────────────────────────
// 只记录「提示是否变化」,避免 timeupdate(约每秒 4 次)每次都写 state 重渲染。
const activeSkipKeyRef = useRef('')
// 监听播放位置,判断当前是否落在某个可跳过区间里。
useEffect(() => {
const video = ref.current
if (!video || skipSegments.length === 0) return
const sync = () => {
// HLS 转码时 <video>.currentTime 是相对转码起点的,必须把起点加回来。这里与
// 进度上报用的算法保持一致,否则转码场景下整段判断都会错位。
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = offset + (video.currentTime || 0)
const next = resolveActiveSkip(absolute, skipSegments, {
hasNextEpisode: Boolean(nextEpisode),
excludedKinds: dismissedSkipKinds,
})
const key = next ? `${next.kind}:${next.startSec}` : ''
if (key === activeSkipKeyRef.current) return
activeSkipKeyRef.current = key
setActiveSkip(next)
}
sync()
video.addEventListener('timeupdate', sync)
return () => video.removeEventListener('timeupdate', sync)
}, [skipSegments, nextEpisode, dismissedSkipKinds, mediaId])
// 用户主动跳进片头/回顾区间(多半是想重看):本次播放不再自动跳过该类型,
// 但按钮保留。只关心会被自动跳过的两种类型。
useEffect(() => {
const video = ref.current
if (!video || skipSegments.length === 0) return
const onSeeked = () => {
if (selfSkipSeekRef.current) {
selfSkipSeekRef.current = false
return
}
const offset = modeRef.current === 'hls' ? hlsStartSecRef.current : 0
const absolute = offset + (video.currentTime || 0)
const hit = skipSegments.find(
(segment) =>
(segment.kind === 'intro' || segment.kind === 'recap') &&
absolute >= segment.startSec &&
absolute < segment.endSec,
)
if (!hit) return
setAutoSuppressedKinds((prev) => (prev.includes(hit.kind) ? prev : [...prev, hit.kind]))
}
video.addEventListener('seeked', onSeeked)
return () => video.removeEventListener('seeked', onSeeked)
}, [skipSegments, mediaId])
const playEpisode = useCallback( const playEpisode = useCallback(
(target: Media) => { (target: Media) => {
navigate( navigate(
@@ -1486,6 +1599,88 @@ export function PlayerPage() {
[hlsStartSec, mode], [hlsStartSec, mode],
) )
// ── 片头/片尾跳过:执行 ────────────────────────────────────────────────────
// 跳到源时间轴上的绝对秒数。HLS 转码时不能直接写 currentTime:本地 HLS 交给
// handleSeekAbsolute(超出缓冲窗口时会从头起一段新转码),云端 HLS 按转码起点换算。
const seekPlaybackTo = useCallback(
(absoluteSec: number) => {
const video = ref.current
if (!video) return
const target = Math.max(0, absoluteSec)
if (mode === 'hls') {
if (hlsSource === 'local') {
handleSeekAbsolute(target)
return
}
selfSkipSeekRef.current = true
video.currentTime = Math.max(0, target - hlsStartSec)
return
}
selfSkipSeekRef.current = true
video.currentTime = target
},
[handleSeekAbsolute, hlsSource, hlsStartSec, mode],
)
const showSkipNotice = useCallback(
(notice: { text: string; startSec: number; kind: PlaybackSegmentKind }) => {
setSkipNotice(notice)
if (skipNoticeTimerRef.current) clearTimeout(skipNoticeTimerRef.current)
skipNoticeTimerRef.current = setTimeout(() => setSkipNotice(null), SKIP_NOTICE_MS)
},
[],
)
// 执行一次跳过:片尾有下一集就直接进下一集,其余情况跳到区间终点。
const performSkip = useCallback(
(prompt: SkipPrompt) => {
setDismissedSkipKinds((prev) => (prev.includes(prompt.kind) ? prev : [...prev, prompt.kind]))
setActiveSkip(null)
activeSkipKeyRef.current = ''
const isOutro = prompt.kind === 'credits' || prompt.kind === 'preview'
if (isOutro && nextEpisode) {
toast.success(`正在播放下一集:${nextEpisodeTitle || '下一集'}`)
playEpisode(nextEpisode)
return
}
seekPlaybackTo(prompt.endSec)
},
[nextEpisode, nextEpisodeTitle, playEpisode, seekPlaybackTo],
)
const handleSkipClick = useCallback(() => {
if (activeSkip) performSkip(activeSkip)
}, [activeSkip, performSkip])
const handleUndoSkip = useCallback(() => {
const notice = skipNotice
setSkipNotice(null)
if (skipNoticeTimerRef.current) {
clearTimeout(skipNoticeTimerRef.current)
skipNoticeTimerRef.current = null
}
if (!notice) return
// 撤销后本次播放不再自动跳这一段,否则会被立刻再跳一次;同时恢复按钮,
// 用户改主意时还能手动跳。
setAutoSuppressedKinds((prev) => (prev.includes(notice.kind) ? prev : [...prev, notice.kind]))
setDismissedSkipKinds((prev) => prev.filter((kind) => kind !== notice.kind))
seekPlaybackTo(notice.startSec)
}, [seekPlaybackTo, skipNotice])
// 自动跳过只针对片头(含回顾):档案里的开关本身就叫「自动跳过片头」,而且自动
// 跳到结尾会让没开自动连播的用户莫名其妙。片尾始终只提供按钮。
useEffect(() => {
if (!autoSkipIntro || !activeSkip) return
if (activeSkip.kind !== 'intro' && activeSkip.kind !== 'recap') return
if (autoSuppressedKinds.includes(activeSkip.kind)) return
performSkip(activeSkip)
showSkipNotice({
text: skippedNoticeText(activeSkip.kind),
startSec: activeSkip.startSec,
kind: activeSkip.kind,
})
}, [activeSkip, autoSkipIntro, autoSuppressedKinds, performSkip, showSkipNotice])
// 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。 // 用户切换图片字幕时从当前位置创建新的 HLS 烧录任务;文本字幕只在网页层切换。
const selectSubtitle = useCallback((index: number) => { const selectSubtitle = useCallback((index: number) => {
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
@@ -1754,6 +1949,10 @@ export function PlayerPage() {
onVr360Error={handleVr360Error} onVr360Error={handleVr360Error}
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false} showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
onDismissVr360Guide={dismissVr360Guide} onDismissVr360Guide={dismissVr360Guide}
skipPrompt={activeSkip}
onSkipPrompt={handleSkipClick}
skipNotice={skipNotice ? { text: skipNotice.text } : null}
onUndoSkip={handleUndoSkip}
playlistPanel={ playlistPanel={
isMobileTheater isMobileTheater
? undefined ? undefined
+48
View File
@@ -8,7 +8,9 @@ import {
LockOpen, LockOpen,
MousePointerClick, MousePointerClick,
Rotate3d, Rotate3d,
SkipForward,
Smartphone, Smartphone,
Undo2,
ZoomIn, ZoomIn,
} from 'lucide-react' } from 'lucide-react'
@@ -30,6 +32,7 @@ import {
type SubtitleStylePreset, type SubtitleStylePreset,
} from '../utils/subtitleDisplay' } from '../utils/subtitleDisplay'
import { isPointerInLockZone } from '../utils/playerLockZone' import { isPointerInLockZone } from '../utils/playerLockZone'
import type { SkipPrompt } from '../utils/skipSegments'
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT' import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
type SubtitleRenderGroup = { type SubtitleRenderGroup = {
@@ -233,6 +236,13 @@ type PlayerVideoStageProps = {
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */ /** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
showVr360Guide?: boolean showVr360Guide?: boolean
onDismissVr360Guide?: () => void onDismissVr360Guide?: () => void
/** 当前落进的片头/片尾区间;null 表示不显示跳过按钮。 */
skipPrompt?: SkipPrompt | null
/** 点击跳过按钮。 */
onSkipPrompt?: () => void
/** 自动跳过后的撤销提示;null 表示不显示。 */
skipNotice?: { text: string } | null
onUndoSkip?: () => void
waiting?: boolean waiting?: boolean
waitingMessage?: string waitingMessage?: string
} }
@@ -300,6 +310,10 @@ export function PlayerVideoStage({
onVr360Error, onVr360Error,
showVr360Guide = false, showVr360Guide = false,
onDismissVr360Guide, onDismissVr360Guide,
skipPrompt = null,
onSkipPrompt,
skipNotice = null,
onUndoSkip,
waiting = false, waiting = false,
waitingMessage = '', waitingMessage = '',
}: PlayerVideoStageProps) { }: PlayerVideoStageProps) {
@@ -919,6 +933,40 @@ export function PlayerVideoStage({
vr360Detected={vr360Detected} vr360Detected={vr360Detected}
onToggleVr360={onToggleVr360} onToggleVr360={onToggleVr360}
/> />
{/* 跳过片头/片尾:贴在操作栏上方右侧。刻意不随操作栏一起自动隐藏——
用户想在「干净画面」下也能跳过;锁定画面时才一并藏起来。 */}
{!locked && skipPrompt ? (
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onSkipPrompt?.()
}}
onPointerDown={(event) => event.stopPropagation()}
className="absolute bottom-16 right-3 z-30 inline-flex items-center gap-2 rounded-xl border border-white/15 bg-black/70 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_8px_28px_rgba(0,0,0,0.5)] backdrop-blur-md transition hover:bg-black/85 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-rose-400 sm:bottom-20 sm:right-5"
>
<SkipForward size={16} />
<span>{skipPrompt.label}</span>
</button>
) : null}
{/* 自动跳过的撤销入口:进度条自己动过之后必须给用户一条退路。 */}
{!locked && skipNotice ? (
<div className="absolute bottom-16 right-3 z-30 flex items-center gap-3 rounded-xl border border-white/15 bg-black/70 px-3.5 py-2 text-xs text-white/90 shadow-[0_8px_28px_rgba(0,0,0,0.5)] backdrop-blur-md sm:bottom-20 sm:right-5">
<span>{skipNotice.text}</span>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
onUndoSkip?.()
}}
onPointerDown={(event) => event.stopPropagation()}
className="inline-flex items-center gap-1 rounded-lg bg-white/15 px-2.5 py-1 font-semibold text-white transition hover:bg-white/25"
>
<Undo2 size={13} />
撤销
</button>
</div>
) : null}
{danmakuPanel} {danmakuPanel}
{playlistPanel} {playlistPanel}
</> </>
+16
View File
@@ -29,3 +29,19 @@ export interface PlaybackInfo {
transcode: PlaybackTranscodeState transcode: PlaybackTranscodeState
} }
/** 可跳过的区间类型,与后端 media_segments.kind 一一对应。 */
export type PlaybackSegmentKind = 'intro' | 'recap' | 'credits' | 'preview'
export interface PlaybackSegment {
kind: PlaybackSegmentKind
start_ms: number
/** 0 表示区间一直延续到片尾(后端把 end_ms: null 落成 0),需按媒体时长补齐。 */
end_ms: number
}
export interface PlaybackSegmentsResponse {
segments: PlaybackSegment[]
/** 当前生效播放档案的「自动跳过片头」开关。 */
auto_skip: boolean
}
+107
View File
@@ -0,0 +1,107 @@
import { resolveActiveSkip, skippedNoticeText, skipLabel, toSkipSegments } from './skipSegments.ts'
function check(name: string, condition: boolean) {
if (!condition) throw new Error(`skipSegments: ${name}`)
}
function equal<T>(name: string, actual: T, expected: T) {
if (actual !== expected) {
throw new Error(`skipSegments: ${name} — got ${String(actual)}, want ${String(expected)}`)
}
}
// ── toSkipSegments ──────────────────────────────────────────────────────────
check('空片段返回空数组', toSkipSegments(null, 600).length === 0)
check('空数组返回空数组', toSkipSegments([], 600).length === 0)
// end_ms: 0 表示延续到片尾,用媒体时长补齐。
const resolved = toSkipSegments(
[
{ kind: 'intro', start_ms: 228_664, end_ms: 246_143 },
{ kind: 'credits', start_ms: 3_431_000, end_ms: 0 },
],
3600,
)
equal('区间数量', resolved.length, 2)
equal('片头起点(秒)', resolved[0].startSec, 228.664)
equal('片头终点(秒)', resolved[0].endSec, 246.143)
equal('片尾终点补齐为总时长', resolved[1].endSec, 3600)
// 时长未知时,开放区间必须丢弃:否则会算出一个永远命中的区间。
check(
'时长未知时丢弃开放区间',
toSkipSegments([{ kind: 'credits', start_ms: 1000, end_ms: 0 }], 0).length === 0,
)
// 无效区间(end <= start)没有任何可跳过内容,不能生成按钮。
check(
'丢弃空区间',
toSkipSegments(
[
{ kind: 'intro', start_ms: 5000, end_ms: 5000 },
{ kind: 'recap', start_ms: 9000, end_ms: 8000 },
],
600,
).length === 0,
)
// 服务端已按类型排序,但客户端仍要按实际时间排,避免同类型多段时顺序错乱。
const ordered = toSkipSegments(
[
{ kind: 'credits', start_ms: 900_000, end_ms: 960_000 },
{ kind: 'intro', start_ms: 100_000, end_ms: 200_000 },
],
3600,
)
equal('按起点排序', ordered[0].kind, 'intro')
// ── resolveActiveSkip ───────────────────────────────────────────────────────
const segments = toSkipSegments(
[
{ kind: 'intro', start_ms: 100_000, end_ms: 200_000 },
{ kind: 'credits', start_ms: 3_400_000, end_ms: 3_500_000 },
],
3600,
)
check('区间之前不提示', resolveActiveSkip(50, segments) === null)
check('区间之中提示', resolveActiveSkip(150, segments)?.kind === 'intro')
check('区间结束后不提示', resolveActiveSkip(250, segments) === null)
check('起点即提示', resolveActiveSkip(100, segments)?.kind === 'intro')
check('终点视为已离开(右开区间)', resolveActiveSkip(200, segments) === null)
check('容差内提前提示', resolveActiveSkip(99.9, segments)?.kind === 'intro')
check('非有限值不提示', resolveActiveSkip(Number.NaN, segments) === null)
// 撤消过的类型不再提示;同一位置的其它类型不受影响。
check(
'排除已处理的类型',
resolveActiveSkip(150, segments, { excludedKinds: ['intro'] }) === null,
)
check(
'排除只影响指定类型',
resolveActiveSkip(3450, segments, { excludedKinds: ['intro'] })?.kind === 'credits',
)
// ── 文案 ────────────────────────────────────────────────────────────────────
equal('片头文案', skipLabel('intro', false), '跳过片头')
equal('回顾也归入片头', skipLabel('recap', false), '跳过片头')
equal('最后一集的片尾', skipLabel('credits', false), '跳过片尾')
equal('有下一集时片尾变成下一集', skipLabel('credits', true), '下一集')
equal('预告同理', skipLabel('preview', true), '下一集')
equal(
'提示里带上有下一集的文案',
resolveActiveSkip(3450, segments, { hasNextEpisode: true })?.label,
'下一集',
)
// ── 自动跳过后的提示文案 ────────────────────────────────────────────────────
equal('片头提示', skippedNoticeText('intro'), '已跳过片头')
equal('回顾提示', skippedNoticeText('recap'), '已跳过回顾')
equal('片尾提示', skippedNoticeText('credits'), '已跳过片尾')
equal('预告按片尾处理', skippedNoticeText('preview'), '已跳过片尾')
console.log('skipSegments.test.ts ok')
+87
View File
@@ -0,0 +1,87 @@
import type { PlaybackSegment, PlaybackSegmentKind } from '../types/playback'
/** 落在源时间轴上的可跳过区间(秒)。 */
export interface SkipSegment {
kind: PlaybackSegmentKind
startSec: number
endSec: number
}
/** 当前应当在浮层上提示的跳过动作。 */
export interface SkipPrompt extends SkipSegment {
label: string
}
/** 片头/回顾统一叫「跳过片头」;片尾在有下一集时直接叫「下一集」。 */
export function skipLabel(kind: PlaybackSegmentKind, hasNextEpisode: boolean): string {
if (kind === 'credits' || kind === 'preview') {
return hasNextEpisode ? '下一集' : '跳过片尾'
}
return '跳过片头'
}
/**
* 自动跳过后的提示文案。自动跳转很容易被当成「进度条自己动了」,所以必须告诉
* 用户发生了什么,并给一个撤销入口。
*/
export function skippedNoticeText(kind: PlaybackSegmentKind): string {
if (kind === 'credits' || kind === 'preview') return '已跳过片尾'
if (kind === 'recap') return '已跳过回顾'
return '已跳过片头'
}
/**
* 把服务端返回的片段换算成源时间轴上的秒区间。
*
* end_ms === 0 表示区间延续到片尾,用媒体总时长补齐;时长未知(0 / 非有限值)
* 时丢弃该区间——否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。
*/
export function toSkipSegments(
segments: PlaybackSegment[] | null | undefined,
durationSec: number,
): SkipSegment[] {
if (!segments || segments.length === 0) return []
const out: SkipSegment[] = []
for (const segment of segments) {
const startSec = Math.max(0, segment.start_ms / 1000)
const endSec = segment.end_ms > 0 ? segment.end_ms / 1000 : durationSec
if (!Number.isFinite(endSec) || endSec <= startSec) continue
out.push({ kind: segment.kind, startSec, endSec })
}
return out.sort((a, b) => a.startSec - b.startSec)
}
export interface ResolveActiveSkipOptions {
hasNextEpisode?: boolean
/** 本次播放内已经跳过 / 被用户撤销过的类型,不再提示。 */
excludedKinds?: PlaybackSegmentKind[]
/** 允许提前一点点出现按钮,避免恰好卡在时间轴上时迟到。 */
toleranceSec?: number
}
/**
* 找出当前播放位置命中的可跳过区间。同一位置有多个区间时取最早开始的那个。
*
* 注意 currentSec 必须是「源时间轴」上的绝对秒数:HLS 转码时 <video>.currentTime
* 是相对转码起点的,调用方要用 streamOffset 加回来,否则转码场景整段判断都会错位。
*/
export function resolveActiveSkip(
currentSec: number,
segments: SkipSegment[],
options: ResolveActiveSkipOptions = {},
): SkipPrompt | null {
const { hasNextEpisode = false, excludedKinds = [], toleranceSec = 0.25 } = options
if (!Number.isFinite(currentSec)) return null
for (const segment of segments) {
if (excludedKinds.includes(segment.kind)) continue
if (currentSec < segment.startSec - toleranceSec) continue
if (currentSec >= segment.endSec) continue
return {
kind: segment.kind,
startSec: segment.startSec,
endSec: segment.endSec,
label: skipLabel(segment.kind, hasNextEpisode),
}
}
return null
}