mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
添加跳过片头片尾功能
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,9 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/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("/Persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/Genres", embyGenresHandler(svc))
|
||||
|
||||
@@ -42,7 +42,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/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("/persons", embyEmptyItemsHandler(svc))
|
||||
auth.GET("/genres", embyGenresHandler(svc))
|
||||
|
||||
@@ -346,6 +346,9 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
&model.Library{},
|
||||
&model.Media{},
|
||||
&model.PlayProfile{},
|
||||
&model.Series{},
|
||||
&model.MediaSegment{},
|
||||
&model.MediaSegmentFetch{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -408,6 +411,7 @@ func newPlaybackScopeTestRouter(t *testing.T) (*gin.Engine, *service.Container,
|
||||
api := router.Group("/api")
|
||||
api.Use(middleware.AuthRequired(cfg.Secrets.JWTSecret))
|
||||
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-players", externalPlayersHandler(svc))
|
||||
api.GET("/stream/:id", streamHandler(svc))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
authed.GET("/playback/:id/info", playbackInfoHandler(svc))
|
||||
authed.GET("/playback/:id/resume", playbackResumeHandler(svc))
|
||||
authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc))
|
||||
authed.POST("/playback/:id/progress", playbackProgressHandler(svc))
|
||||
authed.GET("/playback/:id/external-players", externalPlayersHandler(svc))
|
||||
authed.GET("/playback/:id/external-url", externalURLHandler(svc))
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestAuthenticatedRouteSurfacesAreRegistered(t *testing.T) {
|
||||
"GET /api/storage",
|
||||
"GET /api/watch-history",
|
||||
"GET /api/playback/:id/info",
|
||||
"GET /api/playback/:id/segments",
|
||||
} {
|
||||
if !routes[want] {
|
||||
t.Fatalf("%s route is not registered", want)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -38,6 +38,8 @@ func AllModels() []interface{} {
|
||||
&Series{},
|
||||
&Media{},
|
||||
&PlaybackHistory{},
|
||||
&MediaSegment{},
|
||||
&MediaSegmentFetch{},
|
||||
&Favorite{},
|
||||
&Playlist{},
|
||||
&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)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ type Container struct {
|
||||
Media *MediaRepository
|
||||
Series *SeriesRepository
|
||||
History *HistoryRepository
|
||||
MediaSegment *MediaSegmentRepository
|
||||
Favorite *FavoriteRepository
|
||||
Playlist *PlaylistRepository
|
||||
Setting *SettingRepository
|
||||
@@ -45,6 +46,7 @@ func New(db *gorm.DB) *Container {
|
||||
Media: &MediaRepository{db: db},
|
||||
Series: &SeriesRepository{db: db},
|
||||
History: &HistoryRepository{db: db},
|
||||
MediaSegment: &MediaSegmentRepository{db: db},
|
||||
Favorite: &FavoriteRepository{db: db},
|
||||
Playlist: &PlaylistRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package service — TheIntroDB client.
|
||||
//
|
||||
// TheIntroDB (https://theintrodb.org) is a community database of "skip"
|
||||
// timestamps: intro, recap, end credits and previews. Reads are public and
|
||||
// need no API key, which is what makes it usable as an automatic filler for
|
||||
// the player's 跳过片头/片尾 feature.
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
// IntroDBBaseURL is the public read endpoint. Overridable on the service
|
||||
// for tests and for pointing at a mirror.
|
||||
IntroDBBaseURL = "https://api.theintrodb.org/v3"
|
||||
// IntroDBSource tags rows that came from this provider.
|
||||
IntroDBSource = "theintrodb"
|
||||
|
||||
introDBTimeout = 8 * time.Second
|
||||
introDBMaxBodySize = 1 << 20
|
||||
)
|
||||
|
||||
// IntroDBSpan is one resolved skip range, still in provider terms.
|
||||
// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns
|
||||
// end_ms: null for end credits); the caller resolves it against the duration.
|
||||
type IntroDBSpan struct {
|
||||
Kind string
|
||||
StartMs int64
|
||||
EndMs int64
|
||||
}
|
||||
|
||||
// IntroDBService queries TheIntroDB for one media item.
|
||||
type IntroDBService struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewIntroDBService is the constructor. The client honours environment and OS
|
||||
// proxy settings so it behaves like the other third-party API clients.
|
||||
func NewIntroDBService(log *zap.Logger) *IntroDBService {
|
||||
return &IntroDBService{
|
||||
log: log,
|
||||
client: NewExternalHTTPClient(introDBTimeout),
|
||||
baseURL: IntroDBBaseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// SetBaseURL overrides the API root (tests, mirrors).
|
||||
func (s *IntroDBService) SetBaseURL(base string) *IntroDBService {
|
||||
if s != nil && strings.TrimSpace(base) != "" {
|
||||
s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// introDBRange mirrors one entry of a segment array. start_ms/end_ms are
|
||||
// pointers because the API distinguishes null (= open-ended) from 0.
|
||||
type introDBRange struct {
|
||||
StartMs *int64 `json:"start_ms"`
|
||||
EndMs *int64 `json:"end_ms"`
|
||||
}
|
||||
|
||||
type introDBResponse struct {
|
||||
TMDbID int `json:"tmdb_id"`
|
||||
Type string `json:"type"`
|
||||
Intro []introDBRange `json:"intro"`
|
||||
Recap []introDBRange `json:"recap"`
|
||||
Credits []introDBRange `json:"credits"`
|
||||
Preview []introDBRange `json:"preview"`
|
||||
}
|
||||
|
||||
// Fetch returns the skip ranges TheIntroDB knows about. A 404 means the
|
||||
// database simply has nothing for this title, which is not an error: the
|
||||
// caller records it as a negative cache entry.
|
||||
//
|
||||
// season/episode are required for TV; pass 0/0 for movies.
|
||||
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return nil, errors.New("introdb service nil")
|
||||
}
|
||||
if tmdbID <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
endpoint := s.mediaURL(tmdbID, season, episode)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, nil
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBMaxBodySize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseIntroDBResponse(body)
|
||||
}
|
||||
|
||||
func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(s.baseURL)
|
||||
b.WriteString("/media?tmdb_id=")
|
||||
b.WriteString(strconv.Itoa(tmdbID))
|
||||
// TheIntroDB 对剧集必须带 season+episode,只给 tmdb_id 会返回 404。
|
||||
if season > 0 && episode > 0 {
|
||||
b.WriteString("&season=")
|
||||
b.WriteString(strconv.Itoa(season))
|
||||
b.WriteString("&episode=")
|
||||
b.WriteString(strconv.Itoa(episode))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseIntroDBResponse flattens the per-type arrays into spans, preserving the
|
||||
// intro -> recap -> credits -> preview order so the player sees the earliest
|
||||
// range first.
|
||||
func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) {
|
||||
var raw introDBResponse
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse introdb json: %w", err)
|
||||
}
|
||||
groups := []struct {
|
||||
kind string
|
||||
ranges []introDBRange
|
||||
}{
|
||||
{"intro", raw.Intro},
|
||||
{"recap", raw.Recap},
|
||||
{"credits", raw.Credits},
|
||||
{"preview", raw.Preview},
|
||||
}
|
||||
spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits))
|
||||
for _, group := range groups {
|
||||
for _, r := range group.ranges {
|
||||
var start int64
|
||||
if r.StartMs != nil {
|
||||
start = *r.StartMs
|
||||
}
|
||||
var end int64
|
||||
if r.EndMs != nil {
|
||||
end = *r.EndMs
|
||||
}
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
// end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start,
|
||||
// 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。
|
||||
if end != 0 && end <= start {
|
||||
continue
|
||||
}
|
||||
spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end})
|
||||
}
|
||||
}
|
||||
return spans, nil
|
||||
}
|
||||
|
||||
// logIntroDBFailure 只在 debug 级别记录,避免社区库不可达时把日志刷满。
|
||||
func logIntroDBFailure(log *zap.Logger, tmdbID int, err error) {
|
||||
if log == nil || err == nil {
|
||||
return
|
||||
}
|
||||
log.Debug("introdb lookup failed", zap.Int("tmdb_id", tmdbID), zap.Error(err))
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 这两段响应是从 api.theintrodb.org/v3/media 实测抓下来的原文,
|
||||
// 用来锁住 null 语义:start_ms: null = 从片头开始,end_ms: null = 一直到片尾。
|
||||
const (
|
||||
introDBTVPayload = `{"tmdb_id":1396,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":228664,"end_ms":246143}],"credits":[{"start_ms":3431000,"end_ms":null}]}`
|
||||
introDBMoviePayload = `{"tmdb_id":27205,"type":"movie","intro":[{"start_ms":null,"end_ms":38000}]}`
|
||||
)
|
||||
|
||||
func TestParseIntroDBResponseResolvesNullBounds(t *testing.T) {
|
||||
spans, err := parseIntroDBResponse([]byte(introDBTVPayload))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if len(spans) != 2 {
|
||||
t.Fatalf("spans = %d, want 2 (%#v)", len(spans), spans)
|
||||
}
|
||||
if spans[0].Kind != "intro" || spans[0].StartMs != 228_664 || spans[0].EndMs != 246_143 {
|
||||
t.Fatalf("intro span = %#v", spans[0])
|
||||
}
|
||||
// end_ms: null 表示一直到片尾,落成 0 由客户端结合时长补齐。
|
||||
if spans[1].Kind != "credits" || spans[1].StartMs != 3_431_000 || spans[1].EndMs != 0 {
|
||||
t.Fatalf("credits span = %#v", spans[1])
|
||||
}
|
||||
|
||||
movie, err := parseIntroDBResponse([]byte(introDBMoviePayload))
|
||||
if err != nil {
|
||||
t.Fatalf("parse movie: %v", err)
|
||||
}
|
||||
if len(movie) != 1 {
|
||||
t.Fatalf("movie spans = %d, want 1", len(movie))
|
||||
}
|
||||
// start_ms: null = 从片头开始。
|
||||
if movie[0].StartMs != 0 || movie[0].EndMs != 38_000 {
|
||||
t.Fatalf("movie intro span = %#v", movie[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseDropsEmptyRanges(t *testing.T) {
|
||||
body := `{"tmdb_id":1,"type":"movie",
|
||||
"intro":[{"start_ms":5000,"end_ms":5000},{"start_ms":9000,"end_ms":8000},{"start_ms":1000,"end_ms":2000}],
|
||||
"recap":[],"credits":[],"preview":[]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
// 只有 end > start 的区间是可跳过的;end == 0(到片尾)是合法值,此处不涉及。
|
||||
if len(spans) != 1 || spans[0].StartMs != 1_000 || spans[0].EndMs != 2_000 {
|
||||
t.Fatalf("spans = %#v, want only the 1000-2000 range", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntroDBResponseOrdersByType(t *testing.T) {
|
||||
body := `{"tmdb_id":1,"type":"tv","credits":[{"start_ms":900,"end_ms":1000}],
|
||||
"intro":[{"start_ms":100,"end_ms":200}],"recap":[{"start_ms":50,"end_ms":60}]}`
|
||||
spans, err := parseIntroDBResponse([]byte(body))
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
want := []string{"intro", "recap", "credits"}
|
||||
if len(spans) != len(want) {
|
||||
t.Fatalf("spans = %#v, want %d", spans, len(want))
|
||||
}
|
||||
for i, kind := range want {
|
||||
if spans[i].Kind != kind {
|
||||
t.Fatalf("span[%d].kind = %q, want %q", i, spans[i].Kind, kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBMediaURLOnlyAddsSeasonEpisodeForTV(t *testing.T) {
|
||||
svc := NewIntroDBService(zap.NewNop())
|
||||
if got, want := svc.mediaURL(1396, 1, 1),
|
||||
"https://api.theintrodb.org/v3/media?tmdb_id=1396&season=1&episode=1"; got != want {
|
||||
t.Fatalf("tv url = %q, want %q", got, want)
|
||||
}
|
||||
// 电影(season/episode 为 0)不能带季集参数,否则会被当成剧集查不到。
|
||||
if got, want := svc.mediaURL(27205, 0, 0),
|
||||
"https://api.theintrodb.org/v3/media?tmdb_id=27205"; got != want {
|
||||
t.Fatalf("movie url = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchTreatsNotFoundAsNoData(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.Fetch(t.Context(), 999_999, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("404 must not be an error, got %v", err)
|
||||
}
|
||||
if len(spans) != 0 {
|
||||
t.Fatalf("spans = %#v, want none", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchReportsUnexpectedStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
if _, err := svc.Fetch(t.Context(), 1, 0, 0); err == nil {
|
||||
t.Fatal("500 should surface as an error so the caller can keep its cache")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchSkipsRequestWithoutTMDbID(t *testing.T) {
|
||||
calls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.Fetch(t.Context(), 0, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(spans) != 0 || calls != 0 {
|
||||
t.Fatalf("spans = %#v calls = %d, want no request without a tmdb id", spans, calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchParsesBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("tmdb_id"); got != "1396" {
|
||||
t.Errorf("tmdb_id = %q, want 1396", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("season"); got != "1" {
|
||||
t.Errorf("season = %q, want 1", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("episode"); got != "1" {
|
||||
t.Errorf("episode = %q, want 1", got)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBTVPayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)
|
||||
spans, err := svc.Fetch(t.Context(), 1396, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(spans) != 2 || spans[0].Kind != "intro" {
|
||||
t.Fatalf("spans = %#v", spans)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Package service — 片头/片尾片段(intro / recap / credits / preview)。
|
||||
//
|
||||
// 播放器只认本地库里的片段数据;外部提供方(当前为 TheIntroDB)在播放时按需
|
||||
// 补齐并落库,因此同一部片第二次播放时不再产生任何外网请求。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 片段数据的缓存时长。命中过说明社区库里已有记录、数据很少变动,可以放很久;
|
||||
// 未命中说明这部片还没人贡献,隔一段时间再试一次即可——负缓存是必须的,否则
|
||||
// 每次播放一部没有片段数据的影片都会打一次外网。
|
||||
const (
|
||||
segmentFoundTTL = 30 * 24 * time.Hour
|
||||
segmentMissingTTL = 7 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// SegmentView 是播放器消费的最小片段结构,避免把库内字段(source 等)暴露给前端。
|
||||
type SegmentView struct {
|
||||
Kind string `json:"kind"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
}
|
||||
|
||||
// ToSegmentViews 转换库内行为对外视图。
|
||||
func ToSegmentViews(rows []model.MediaSegment) []SegmentView {
|
||||
out := make([]SegmentView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。
|
||||
type MediaSegmentService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
introdb *IntroDBService
|
||||
}
|
||||
|
||||
// NewMediaSegmentService is the constructor.
|
||||
func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService {
|
||||
return &MediaSegmentService{log: log, repo: repo}
|
||||
}
|
||||
|
||||
// SetIntroDB wires the provider. Without it the service only reads cached rows.
|
||||
func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.introdb = p
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListForPlayback returns the segments known for a media item, refreshing from
|
||||
// the provider when the cache is stale.
|
||||
//
|
||||
// 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的,
|
||||
// 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。
|
||||
func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
if s == nil || s.repo == nil || m == nil || m.ID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
cached, err := s.repo.MediaSegment.ListByMedia(ctx, m.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ledger != nil && ledgerFresh(ledger) {
|
||||
return cached, nil
|
||||
}
|
||||
refreshed, attempted, err := s.refresh(ctx, m)
|
||||
if err != nil {
|
||||
// 社区库不可达或返回异常:沿用已有缓存,不影响播放。
|
||||
logIntroDBFailure(s.log, 0, err)
|
||||
return cached, nil
|
||||
}
|
||||
if !attempted {
|
||||
return cached, nil
|
||||
}
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
|
||||
//
|
||||
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
|
||||
// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。
|
||||
func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) {
|
||||
if s.introdb == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
tmdbID, season, episode := s.queryIDs(ctx, m)
|
||||
if tmdbID <= 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
// 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然
|
||||
// 要落库,这样下一次播放直接命中缓存。
|
||||
//
|
||||
// 但 WithoutCancel 会丢掉 deadline,所以这里要主动把调用方原本愿意等待的
|
||||
// 剩余时间取回来:Emby 等第三方客户端会在起播前后同步请求片段,若调用方只
|
||||
// 打算等 5 秒,不能因为一次外网抓取把它拖到 10 秒。
|
||||
budget := introDBTimeout + 2*time.Second
|
||||
if deadline, ok := ctx.Deadline(); ok {
|
||||
if remaining := time.Until(deadline); remaining < budget {
|
||||
budget = remaining
|
||||
}
|
||||
}
|
||||
if budget <= 0 {
|
||||
// 调用方的预算已经用完:直接放弃本次抓取。返回 attempted=false,
|
||||
// 调用方保留自己的缓存,也不会写入负缓存(下次还有机会)。
|
||||
return nil, false, nil
|
||||
}
|
||||
fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget)
|
||||
defer cancel()
|
||||
|
||||
spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
rows := make([]model.MediaSegment, 0, len(spans))
|
||||
for _, span := range spans {
|
||||
rows = append(rows, model.MediaSegment{
|
||||
MediaID: m.ID,
|
||||
SeriesID: m.SeriesID,
|
||||
Kind: span.Kind,
|
||||
StartMs: span.StartMs,
|
||||
EndMs: span.EndMs,
|
||||
Source: IntroDBSource,
|
||||
})
|
||||
}
|
||||
if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{
|
||||
MediaID: m.ID,
|
||||
Source: IntroDBSource,
|
||||
FetchedAt: time.Now(),
|
||||
Found: len(rows) > 0,
|
||||
}); err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
// queryIDs resolves the provider query key. Movies use their own TMDb id;
|
||||
// episodes need the *series* TMDb id plus season/episode, because scraping
|
||||
// stores the episode-level TMDb id on Media.TMDbID.
|
||||
func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
if m.SeriesID == "" || m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
series, err := s.repo.Series.FindByID(ctx, m.SeriesID)
|
||||
if err != nil || series == nil || series.TMDbID <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return series.TMDbID, m.SeasonNum, m.EpisodeNum
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
return m.TMDbID, 0, 0
|
||||
}
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
// ledgerFresh reports whether a previous lookup is still within its TTL.
|
||||
func ledgerFresh(row *model.MediaSegmentFetch) bool {
|
||||
if row == nil {
|
||||
return false
|
||||
}
|
||||
ttl := segmentMissingTTL
|
||||
if row.Found {
|
||||
ttl = segmentFoundTTL
|
||||
}
|
||||
return time.Since(row.FetchedAt) < ttl
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
func newSegmentServiceFixture(t *testing.T, handler http.HandlerFunc) (*MediaSegmentService, *repository.Container, *int32) {
|
||||
t.Helper()
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
handler(w, r)
|
||||
}))
|
||||
t.Cleanup(server.Close)
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
return svc, repos, &calls
|
||||
}
|
||||
|
||||
func writeJSONBody(body string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 剧集必须用「剧集级」TMDb id 查询,而 Media.TMDbID 存的是单集自己的 id:
|
||||
// 刮削写的是 episode 的 tmdb id(见 local_metadata_test.go 的约束)。
|
||||
func TestQueryIDsUsesSeriesTMDbForEpisodes(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := repos.DB.Create(&model.Series{
|
||||
Base: model.Base{ID: "s-1"}, Title: "Breaking Bad", TMDbID: 1396,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episode := &model.Media{
|
||||
Base: model.Base{ID: "ep-1"},
|
||||
SeriesID: "s-1",
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 2,
|
||||
TMDbID: 4375419, // 单集 id,不是剧集 id
|
||||
}
|
||||
tmdbID, season, episodeNum := svc.queryIDs(ctx, episode)
|
||||
if tmdbID != 1396 {
|
||||
t.Fatalf("tmdbID = %d, want the series id 1396 (not the episode id)", tmdbID)
|
||||
}
|
||||
if season != 1 || episodeNum != 2 {
|
||||
t.Fatalf("season/episode = %d/%d, want 1/2", season, episodeNum)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIDsForMovieUsesOwnTMDb(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
|
||||
tmdbID, season, episode := svc.queryIDs(t.Context(), &model.Media{
|
||||
Base: model.Base{ID: "mv-1"}, TMDbID: 27205,
|
||||
})
|
||||
if tmdbID != 27205 || season != 0 || episode != 0 {
|
||||
t.Fatalf("query = (%d,%d,%d), want (27205,0,0)", tmdbID, season, episode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryIDsIsNotResolvableBeforeScrape(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
// 剧集还没关联 Series:解析不出来,但也不能当成「查过且没有」。
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, &model.Media{
|
||||
Base: model.Base{ID: "ep-orphan"}, SeasonNum: 1, EpisodeNum: 1,
|
||||
}); tmdbID != 0 {
|
||||
t.Fatalf("tmdbID = %d, want 0", tmdbID)
|
||||
}
|
||||
// 没刮削过的电影同理。
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, &model.Media{Base: model.Base{ID: "mv-noscrape"}}); tmdbID != 0 {
|
||||
t.Fatalf("tmdbID = %d, want 0", tmdbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackFetchesOnceThenServesCache(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-1"}, Path: "/movies/inception.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rows, err := svc.ListForPlayback(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("call #%d: %v", i+1, err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("call #%d rows = %#v, want 1", i+1, rows)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (later plays must hit the local cache)", got)
|
||||
}
|
||||
got, err := repos.MediaSegment.ListByMedia(ctx, "mv-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Kind != model.SegmentKindIntro || got[0].StartMs != 0 || got[0].EndMs != 38_000 {
|
||||
t.Fatalf("persisted rows = %#v", got)
|
||||
}
|
||||
if got[0].Source != IntroDBSource {
|
||||
t.Fatalf("source = %q, want %q", got[0].Source, IntroDBSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackCachesMisses(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-2"}, Path: "/movies/nobody-knows.mkv", TMDbID: 424242}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for i := 0; i < 2; i++ {
|
||||
rows, err := svc.ListForPlayback(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("call #%d: %v", i+1, err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("call #%d rows = %#v, want none", i+1, rows)
|
||||
}
|
||||
}
|
||||
// 负缓存是必需的:否则每次播放这部片都会重新打一次外网。
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (a miss must be cached too)", got)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-2", IntroDBSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger == nil || ledger.Found {
|
||||
t.Fatalf("ledger = %#v, want a recorded miss", ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackSkipsProviderWithoutExternalID(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-3"}, Path: "/movies/unscraped.mkv"}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := svc.ListForPlayback(ctx, m); err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 0 {
|
||||
t.Fatalf("provider calls = %d, want 0 without a tmdb id", got)
|
||||
}
|
||||
// 关键:解析不出外部 ID 时不能写负缓存,否则刮削完成后就永远不会再查了。
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-3", IntroDBSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger != nil {
|
||||
t.Fatalf("ledger = %#v, want none while metadata is still missing", ledger)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackKeepsCacheWhenProviderFails(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos).
|
||||
SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL))
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-4"}, Path: "/movies/flaky.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if rows, err := svc.ListForPlayback(ctx, m); err != nil || len(rows) != 1 {
|
||||
t.Fatalf("first call rows=%#v err=%v", rows, err)
|
||||
}
|
||||
// 让缓存过期,制造一次会失败的刷新。
|
||||
if err := repos.DB.Model(&model.MediaSegmentFetch{}).
|
||||
Where("media_id = ?", "mv-4").
|
||||
Update("fetched_at", time.Now().Add(-segmentFoundTTL-time.Hour)).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := svc.ListForPlayback(ctx, m)
|
||||
if err != nil {
|
||||
t.Fatalf("provider failure must not surface as an error: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("rows = %#v, want the previous cache kept", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackRespectsCallerDeadline(t *testing.T) {
|
||||
// 第三方客户端(Emby)会在起播路径上同步请求片段,它给的超时必须生效,
|
||||
// 不能被一次外网抓取拖住;同时超时不能变成「负缓存」,否则就再也补不上了。
|
||||
svc, repos, calls := newSegmentServiceFixture(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBMoviePayload))
|
||||
})
|
||||
ctx := t.Context()
|
||||
m := &model.Media{Base: model.Base{ID: "mv-5"}, Path: "/movies/budget.mkv", TMDbID: 27205}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
budgeted, cancel := context.WithTimeout(ctx, 50*time.Millisecond)
|
||||
defer cancel()
|
||||
rows, err := svc.ListForPlayback(budgeted, m)
|
||||
if err != nil {
|
||||
t.Fatalf("an exhausted fetch budget must not surface as an error: %v", err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("rows = %#v, want none when the caller's budget ran out", rows)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1 (the attempt was made then abandoned)", got)
|
||||
}
|
||||
ledger, err := repos.MediaSegment.GetFetch(ctx, "mv-5", IntroDBSource)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ledger != nil {
|
||||
t.Fatal("a timed-out fetch must not be recorded as a negative cache entry")
|
||||
}
|
||||
|
||||
// 预算正常时(下一次播放)仍然能补上。
|
||||
rows, err = svc.ListForPlayback(ctx, m)
|
||||
if err != nil || len(rows) != 1 {
|
||||
t.Fatalf("second call rows=%#v err=%v, want the fetched segment", rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLedgerFreshUsesLongerTTLWhenDataWasFound(t *testing.T) {
|
||||
now := time.Now()
|
||||
found := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: true}
|
||||
if !ledgerFresh(found) {
|
||||
t.Fatal("a hit should still be fresh just past the miss TTL")
|
||||
}
|
||||
miss := &model.MediaSegmentFetch{FetchedAt: now.Add(-segmentMissingTTL), Found: false}
|
||||
if ledgerFresh(miss) {
|
||||
t.Fatal("a miss should expire after the miss TTL")
|
||||
}
|
||||
if ledgerFresh(nil) {
|
||||
t.Fatal("a missing ledger must not be considered fresh")
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ type Container struct {
|
||||
Fanart *FanartProvider
|
||||
Scraper *ScraperService
|
||||
Playback *PlaybackService
|
||||
Segments *MediaSegmentService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Subtitle *SubtitleService
|
||||
|
||||
@@ -130,6 +130,8 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache)
|
||||
b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder)
|
||||
b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote)
|
||||
// 片头/片尾片段:播放时按需向 TheIntroDB 补齐并落库,供下次直接命中。
|
||||
b.c.Segments = NewMediaSegmentService(b.log, b.repos).SetIntroDB(NewIntroDBService(b.log))
|
||||
b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos)
|
||||
b.c.Profile = NewProfileService(b.log, b.repos)
|
||||
b.c.Audit = NewAuditService(b.log, b.repos)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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
|
||||
// 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`)
|
||||
.then((r) => r.data),
|
||||
|
||||
// 片头/片尾片段:播放开始后再调用,服务端可能需要几秒去外部数据库取数,
|
||||
// 因此绝不能让它挡在起播路径上。
|
||||
segments: (mediaId: string) =>
|
||||
api
|
||||
.get<PlaybackSegmentsResponse>(`/playback/${encodeURIComponent(mediaId)}/segments`)
|
||||
.then((r) => r.data),
|
||||
|
||||
recordProgress: (payload: PlaybackProgressRequest) =>
|
||||
api.post('/history', payload).then((r) => r.data),
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ import { subtitlesAPI, type SubtitleTrack } from '../api/subtitles'
|
||||
import { systemAPI } from '../api/system'
|
||||
import { profileAPI } from '../api/profile'
|
||||
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 { mediaVersionMatches, mediaVersionsOf } from '../utils/mediaVersion'
|
||||
import { normalizePlaybackRate } from '../utils/playbackRate'
|
||||
import { resolveActiveSkip, skippedNoticeText, toSkipSegments, type SkipPrompt } from '../utils/skipSegments'
|
||||
import { isRemoteEmbyID } from '../utils/remoteEmby'
|
||||
import {
|
||||
normalizeSubtitleChineseMode,
|
||||
@@ -71,6 +72,9 @@ type PlaybackProgressSession = {
|
||||
sequence: number
|
||||
}
|
||||
|
||||
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
|
||||
const SKIP_NOTICE_MS = 6000
|
||||
|
||||
function normalizePlayerVolume(value: unknown): number {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
@@ -697,6 +701,63 @@ export function PlayerPage() {
|
||||
cloudRetryRef.current = Math.max(3, playbackInfo?.transcode.retry_after_sec || 5)
|
||||
}, [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(
|
||||
(position = 0) => {
|
||||
setHlsSource('local')
|
||||
@@ -1217,6 +1278,58 @@ export function PlayerPage() {
|
||||
return nextEpisode ? formatEpisodeDisplay(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(
|
||||
(target: Media) => {
|
||||
navigate(
|
||||
@@ -1486,6 +1599,88 @@ export function PlayerPage() {
|
||||
[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 烧录任务;文本字幕只在网页层切换。
|
||||
const selectSubtitle = useCallback((index: number) => {
|
||||
const oldTrack = subtitleIndex >= 0 ? subs[subtitleIndex] : undefined
|
||||
@@ -1754,6 +1949,10 @@ export function PlayerPage() {
|
||||
onVr360Error={handleVr360Error}
|
||||
showVr360Guide={Boolean(vr360) && vr360GuideSeen === false}
|
||||
onDismissVr360Guide={dismissVr360Guide}
|
||||
skipPrompt={activeSkip}
|
||||
onSkipPrompt={handleSkipClick}
|
||||
skipNotice={skipNotice ? { text: skipNotice.text } : null}
|
||||
onUndoSkip={handleUndoSkip}
|
||||
playlistPanel={
|
||||
isMobileTheater
|
||||
? undefined
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
LockOpen,
|
||||
MousePointerClick,
|
||||
Rotate3d,
|
||||
SkipForward,
|
||||
Smartphone,
|
||||
Undo2,
|
||||
ZoomIn,
|
||||
} from 'lucide-react'
|
||||
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type SubtitleStylePreset,
|
||||
} from '../utils/subtitleDisplay'
|
||||
import { isPointerInLockZone } from '../utils/playerLockZone'
|
||||
import type { SkipPrompt } from '../utils/skipSegments'
|
||||
import { parseWebVTTCues, type SubtitleCue } from '../utils/subtitleVTT'
|
||||
|
||||
type SubtitleRenderGroup = {
|
||||
@@ -233,6 +236,13 @@ type PlayerVideoStageProps = {
|
||||
/** 是否展示 VR 全景播放的首次操作说明(按用户只弹一次)。 */
|
||||
showVr360Guide?: boolean
|
||||
onDismissVr360Guide?: () => void
|
||||
/** 当前落进的片头/片尾区间;null 表示不显示跳过按钮。 */
|
||||
skipPrompt?: SkipPrompt | null
|
||||
/** 点击跳过按钮。 */
|
||||
onSkipPrompt?: () => void
|
||||
/** 自动跳过后的撤销提示;null 表示不显示。 */
|
||||
skipNotice?: { text: string } | null
|
||||
onUndoSkip?: () => void
|
||||
waiting?: boolean
|
||||
waitingMessage?: string
|
||||
}
|
||||
@@ -300,6 +310,10 @@ export function PlayerVideoStage({
|
||||
onVr360Error,
|
||||
showVr360Guide = false,
|
||||
onDismissVr360Guide,
|
||||
skipPrompt = null,
|
||||
onSkipPrompt,
|
||||
skipNotice = null,
|
||||
onUndoSkip,
|
||||
waiting = false,
|
||||
waitingMessage = '',
|
||||
}: PlayerVideoStageProps) {
|
||||
@@ -919,6 +933,40 @@ export function PlayerVideoStage({
|
||||
vr360Detected={vr360Detected}
|
||||
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}
|
||||
{playlistPanel}
|
||||
</>
|
||||
|
||||
@@ -29,3 +29,19 @@ export interface PlaybackInfo {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user