diff --git a/internal/handler/emby_media_segments.go b/internal/handler/emby_media_segments.go new file mode 100644 index 0000000..dd38218 --- /dev/null +++ b/internal/handler/emby_media_segments.go @@ -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 = {"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 +} diff --git a/internal/handler/emby_media_segments_test.go b/internal/handler/emby_media_segments_test.go new file mode 100644 index 0000000..a951496 --- /dev/null +++ b/internal/handler/emby_media_segments_test.go @@ -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) + } +} diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 51df371..4adc4a7 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -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)) diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index 8d3a432..afc5f70 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -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)) diff --git a/internal/handler/playback_extra_test.go b/internal/handler/playback_extra_test.go index a51623d..aa52a5d 100644 --- a/internal/handler/playback_extra_test.go +++ b/internal/handler/playback_extra_test.go @@ -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)) diff --git a/internal/handler/playback_segments.go b/internal/handler/playback_segments.go new file mode 100644 index 0000000..35d5abe --- /dev/null +++ b/internal/handler/playback_segments.go @@ -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 +} diff --git a/internal/handler/playback_segments_test.go b/internal/handler/playback_segments_test.go new file mode 100644 index 0000000..fb1a3ab --- /dev/null +++ b/internal/handler/playback_segments_test.go @@ -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()) + } +} diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index eddabd2..e638d69 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -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)) diff --git a/internal/handler/routes_authenticated_test.go b/internal/handler/routes_authenticated_test.go index d7e6ae1..fe9186c 100644 --- a/internal/handler/routes_authenticated_test.go +++ b/internal/handler/routes_authenticated_test.go @@ -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) diff --git a/internal/model/media_segment.go b/internal/model/media_segment.go new file mode 100644 index 0000000..0b2e8c4 --- /dev/null +++ b/internal/model/media_segment.go @@ -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"` +} diff --git a/internal/model/model.go b/internal/model/model.go index 751bb57..fa5f7dd 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -38,6 +38,8 @@ func AllModels() []interface{} { &Series{}, &Media{}, &PlaybackHistory{}, + &MediaSegment{}, + &MediaSegmentFetch{}, &Favorite{}, &Playlist{}, &PlaylistItem{}, diff --git a/internal/repository/media_segment_repository.go b/internal/repository/media_segment_repository.go new file mode 100644 index 0000000..df66e11 --- /dev/null +++ b/internal/repository/media_segment_repository.go @@ -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 +} diff --git a/internal/repository/media_segment_repository_test.go b/internal/repository/media_segment_repository_test.go new file mode 100644 index 0000000..c4ac6b7 --- /dev/null +++ b/internal/repository/media_segment_repository_test.go @@ -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) + } +} diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 0cf8b79..343f44c 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -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}, diff --git a/internal/service/introdb.go b/internal/service/introdb.go new file mode 100644 index 0000000..dd24923 --- /dev/null +++ b/internal/service/introdb.go @@ -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)) +} diff --git a/internal/service/introdb_test.go b/internal/service/introdb_test.go new file mode 100644 index 0000000..0f178ab --- /dev/null +++ b/internal/service/introdb_test.go @@ -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) + } +} diff --git a/internal/service/media_segment.go b/internal/service/media_segment.go new file mode 100644 index 0000000..42d6c4d --- /dev/null +++ b/internal/service/media_segment.go @@ -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 +} diff --git a/internal/service/media_segment_test.go b/internal/service/media_segment_test.go new file mode 100644 index 0000000..e0369c7 --- /dev/null +++ b/internal/service/media_segment_test.go @@ -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") + } +} diff --git a/internal/service/service.go b/internal/service/service.go index fff8202..905d0de 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -35,6 +35,7 @@ type Container struct { Fanart *FanartProvider Scraper *ScraperService Playback *PlaybackService + Segments *MediaSegmentService ImageProxy *ImageProxy Watcher *WatcherService Subtitle *SubtitleService diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index ef2d96c..8ffcfd4 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -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) diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index e5f1ee5..add8ced 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -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(`/playback/${encodeURIComponent(mediaId)}/segments`) + .then((r) => r.data), + recordProgress: (payload: PlaybackProgressRequest) => api.post('/history', payload).then((r) => r.data), diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index a58809a..4890ad2 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -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([]) + const [autoSkipIntro, setAutoSkipIntro] = useState(false) + // 当前落进的跳过提示;不在任何区间时为 null。 + const [activeSkip, setActiveSkip] = useState(null) + // 自动跳过后的撤销提示。 + const [skipNotice, setSkipNotice] = useState<{ + text: string + startSec: number + kind: PlaybackSegmentKind + } | null>(null) + // 用户已经处理过的区间类型:本次播放内不再重复提示。 + const [dismissedSkipKinds, setDismissedSkipKinds] = useState([]) + // 用户主动跳进过片头/片尾区间(多半是想重看):本次播放不再自动跳过该类型, + // 但按钮保留,想跳随时可以点。 + const [autoSuppressedKinds, setAutoSuppressedKinds] = useState([]) + const skipNoticeTimerRef = useRef | 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 转码时