From 9ccf60427a59822fe139ec56efe1d8ddac902402 Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Thu, 22 Oct 2026 15:14:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/server/application.go | 8 + internal/handler/emby_home_compat_test.go | 113 ++++++ internal/handler/emby_media_segments.go | 6 + internal/handler/emby_routes.go | 6 +- internal/handler/emby_routes_lowercase.go | 4 +- internal/handler/playback_segments.go | 73 +++- .../handler/routes_authenticated_extras.go | 1 + internal/repository/media_repository.go | 10 +- .../repository/media_segment_repository.go | 17 + internal/service/emby_compat.go | 18 +- internal/service/episode_metadata_cleanup.go | 40 ++ internal/service/introdb.go | 48 ++- internal/service/introdb_app.go | 160 ++++++++ internal/service/manual_mark_test.go | 165 +++++++++ internal/service/media_segment.go | 344 ++++++++++++++++-- internal/service/media_segment_test.go | 11 +- internal/service/segment_provider.go | 46 +++ internal/service/segment_providers_test.go | 150 ++++++++ internal/service/segment_source_test.go | 188 ++++++++++ internal/service/service_builder.go | 10 +- internal/service/tmdb.go | 33 ++ internal/service/todb.go | 213 +++++++++++ internal/service/todb_test.go | 112 ++++++ introdb_app_tmp.json | 1 + web/src/api/playback.ts | 10 + web/src/pages/PlayerPage.tsx | 83 +++++ web/src/pages/PlayerVideoStage.tsx | 57 +++ web/src/pages/settingsGroupGeneral.ts | 17 +- web/src/utils/skipSegments.test.ts | 13 +- web/src/utils/skipSegments.ts | 20 +- 30 files changed, 1909 insertions(+), 68 deletions(-) create mode 100644 internal/handler/emby_home_compat_test.go create mode 100644 internal/service/introdb_app.go create mode 100644 internal/service/manual_mark_test.go create mode 100644 internal/service/segment_provider.go create mode 100644 internal/service/segment_providers_test.go create mode 100644 internal/service/segment_source_test.go create mode 100644 internal/service/todb.go create mode 100644 internal/service/todb_test.go create mode 100644 introdb_app_tmp.json diff --git a/cmd/server/application.go b/cmd/server/application.go index c31edb8..a962743 100644 --- a/cmd/server/application.go +++ b/cmd/server/application.go @@ -113,6 +113,14 @@ func (a *application) start() error { a.logger.Info("polluted episode metadata cleanup completed", zap.Int("media_count", cleaned)) } + // 一次性删除旧版解析误写的「整集片头/回顾」区间(end_ms <= start_ms), + // 否则升级后播放器仍会一开播就弹跳过按钮。 + if cleaned, err := a.services.NormalizeImpossibleSegments(context.Background()); err != nil { + a.logger.Warn("impossible skip segment cleanup failed", zap.Error(err)) + } else if cleaned > 0 { + a.logger.Info("impossible skip segments removed", zap.Int("rows", cleaned)) + } + if err := a.services.Auth.SeedAdmin(context.Background()); err != nil { a.logger.Warn("seed admin failed", zap.Error(err)) } diff --git a/internal/handler/emby_home_compat_test.go b/internal/handler/emby_home_compat_test.go new file mode 100644 index 0000000..00cded3 --- /dev/null +++ b/internal/handler/emby_home_compat_test.go @@ -0,0 +1,113 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +// 客户端刷新首页时会用 IncludeItemTypes=Movie,Series,Video 一次取「最新」行。 +// 电影库必须返回电影,而不是因为请求里带了 Series 就整行判空。 +func TestEmbyItemsMixedTypesKeepsMoviesInMovieLibrary(t *testing.T) { + router, svc, _ := newEmbyDiscoveryEnv(t) + if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil { + t.Fatalf("migrate: %v", err) + } + libID := seedEmbyLibrary(t, svc, "movie") + if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{ + Base: model.Base{ID: "movie-1"}, + LibraryID: libID, + Title: "Movie One", + Path: "/media/movie/one.mp4", + Year: 2024, + ReleaseDate: "2024-01-01", + }).Error; err != nil { + t.Fatalf("create movie: %v", err) + } + + path := "/emby/Items?ParentId=" + libID + + "&IncludeItemTypes=Movie,Series,Video&Recursive=true" + + "&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0" + w := embyGet(t, router, path, signedTestToken(t, "test-secret")) + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + + items := decodeItemsEnvelope(t, w.Body.Bytes()) + if len(items) != 1 { + t.Fatalf("expected the movie to be returned, got %d items: %s", len(items), w.Body.String()) + } + if items[0]["Id"] != "movie-1" || items[0]["Type"] != "Movie" { + t.Fatalf("unexpected item payload: %#v", items[0]) + } +} + +// 同一组混合类型在剧集库上仍要折叠成剧集卡片,修复不能把这条路径弄回单集。 +func TestEmbyItemsMixedTypesKeepsSeriesCardsInTvLibrary(t *testing.T) { + router, svc, _ := newEmbyDiscoveryEnv(t) + if err := svc.Repo.DB.AutoMigrate(&model.Series{}, &model.PlayProfile{}); err != nil { + t.Fatalf("migrate: %v", err) + } + libID := seedEmbyLibrary(t, svc, "tv") + for _, ep := range []struct { + id string + num int + path string + }{ + {id: "ep-1", num: 1, path: "/media/tv/show/S01E01.mkv"}, + {id: "ep-2", num: 2, path: "/media/tv/show/S01E02.mkv"}, + } { + if err := svc.Repo.DB.WithContext(context.Background()).Create(&model.Media{ + Base: model.Base{ID: ep.id}, + LibraryID: libID, + SeriesID: "series-1", + Title: "Show", + Path: ep.path, + SeasonNum: 1, + EpisodeNum: ep.num, + Year: 2024, + }).Error; err != nil { + t.Fatalf("create episode: %v", err) + } + } + + path := "/emby/Items?ParentId=" + libID + + "&IncludeItemTypes=Movie,Series,Video&Recursive=true" + + "&SortBy=DateCreated,SortName&SortOrder=Descending&Limit=12&StartIndex=0" + w := embyGet(t, router, path, signedTestToken(t, "test-secret")) + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + + items := decodeItemsEnvelope(t, w.Body.Bytes()) + if len(items) != 1 { + t.Fatalf("expected one series card, got %d items: %s", len(items), w.Body.String()) + } + if items[0]["Type"] != "Series" { + t.Fatalf("expected Series card, got %#v", items[0]) + } +} + +// Emby 的 SpecialFeatures 响应是 BaseItemDto[],客户端按 List 强转; +// 返回 {"Items":[]} 会直接抛 type cast 异常。 +func TestEmbySpecialFeaturesReturnsBareArray(t *testing.T) { + router, _, _ := newEmbyDiscoveryEnv(t) + w := embyGet(t, router, "/emby/Items/movie-1/SpecialFeatures", signedTestToken(t, "test-secret")) + if w.Code != http.StatusOK { + t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String()) + } + if trimmed := bytes.TrimSpace(w.Body.Bytes()); len(trimmed) == 0 || trimmed[0] != '[' { + t.Fatalf("SpecialFeatures must be a JSON array, got %s", w.Body.String()) + } + var items []any + if err := json.Unmarshal(w.Body.Bytes(), &items); err != nil { + t.Fatalf("decode array: %v body=%s", err, w.Body.String()) + } + if len(items) != 0 { + t.Fatalf("expected empty array, got %d entries", len(items)) + } +} diff --git a/internal/handler/emby_media_segments.go b/internal/handler/emby_media_segments.go index dd38218..86056dc 100644 --- a/internal/handler/emby_media_segments.go +++ b/internal/handler/emby_media_segments.go @@ -86,6 +86,12 @@ func embySegmentItems(m *model.Media, rows []model.MediaSegment, want map[string } endMs := row.EndMs if endMs <= 0 { + // 只有片尾类允许「延续到片尾」。片头/回顾的结束点缺失意味着这段区间 + // 本身不可信(数据源用 0 表示「这段不存在」),补成片长会让客户端把 + // 整集标成片头,所以直接丢弃。 + if row.Kind != model.SegmentKindCredits && row.Kind != model.SegmentKindPreview { + continue + } if durationMs <= 0 { continue } diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 24080f3..08266a2 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -212,9 +212,11 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con auth.GET("/Items/:id/Similar", embySimilarHandler(svc)) auth.GET("/Items/:id/ThumbnailSet", embyEmptyItemsHandler(svc)) auth.GET("/Items/:id/ThemeMedia", embyThemeMediaHandler(svc)) - auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc)) + // SpecialFeatures 在 Emby 官方 schema 里是 BaseItemDto[](裸数组), + // 返回 {"Items":[]} 会让按 List 强转的客户端抛 type cast 异常。 + auth.GET("/Users/:userId/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc)) auth.GET("/Users/:userId/Items/:id/Intros", embyEmptyItemsHandler(svc)) - auth.GET("/Items/:id/SpecialFeatures", embyEmptyItemsHandler(svc)) + auth.GET("/Items/:id/SpecialFeatures", embyEmptyArrayHandler(svc)) auth.GET("/Items/:id/Intros", embyEmptyItemsHandler(svc)) auth.GET("/api/danmu/:id/raw", embyDanmuRawHandler(svc)) } diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index 7c780a1..659ad10 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -55,9 +55,9 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain auth.GET("/items/:id/similar", embySimilarHandler(svc)) auth.GET("/items/:id/thumbnailset", embyEmptyItemsHandler(svc)) auth.GET("/items/:id/thememedia", embyThemeMediaHandler(svc)) - auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyItemsHandler(svc)) + auth.GET("/users/:userId/items/:id/specialfeatures", embyEmptyArrayHandler(svc)) auth.GET("/users/:userId/items/:id/intros", embyEmptyItemsHandler(svc)) - auth.GET("/items/:id/specialfeatures", embyEmptyItemsHandler(svc)) + auth.GET("/items/:id/specialfeatures", embyEmptyArrayHandler(svc)) auth.GET("/items/:id/intros", embyEmptyItemsHandler(svc)) } diff --git a/internal/handler/playback_segments.go b/internal/handler/playback_segments.go index 35d5abe..8921c2d 100644 --- a/internal/handler/playback_segments.go +++ b/internal/handler/playback_segments.go @@ -1,13 +1,18 @@ // Package handler — 片头/片尾片段接口。 // -// GET /playback/:id/segments +// GET /playback/:id/segments +// PUT /playback/:id/segments {kind, position_ms, clear} // // 单独开一个接口而不是塞进 /media/:id/playback,有两个原因:一是抓取外部数据 // 可能要几秒,不能拖慢决定能否起播的那个请求;二是片段与播放来源(本地 / 云盘 / // 远程 Emby)无关,独立出来对所有媒体一致。 +// +// 写入接口只处理「手动标记」:用户播放时把当前位置标成片头结束 / 片尾开始。 +// 外部数据永远由提供方刷新,不接受客户端写入。 package handler import ( + "errors" "net/http" "github.com/gin-gonic/gin" @@ -42,6 +47,72 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc { } } +// playbackSegmentsMarkRequest 是手动标记的入参。 +type playbackSegmentsMarkRequest struct { + // Kind 只接受 intro / credits。 + Kind string `json:"kind" binding:"required"` + // PositionMs 是当前播放位置:标片头时表示片头结束点,标片尾时表示片尾开始点。 + PositionMs int64 `json:"position_ms"` + // Clear 为 true 时删除该类型的标记。 + Clear bool `json:"clear"` +} + +// playbackSegmentsMarkHandler 写入或清除一条手动标记,返回更新后的完整片段列表。 +// +// 返回整份列表而不是只回 204:播放器需要立刻反映合并后的结果(手动标记优先级 +// 最高),避免自己再发一次 GET 造成竞态。 +func playbackSegmentsMarkHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + if svc.Segments == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "segments unavailable"}) + return + } + m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id")) + if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { + c.JSON(http.StatusNotFound, gin.H{"error": "media not found"}) + return + } + // 远程 Emby 挂载是上游库的投影,本地标记没有可落地的地方。 + if service.IsEmbyRemoteID(m.ID) { + c.JSON(http.StatusBadRequest, gin.H{"error": "remote media cannot be marked"}) + return + } + var req playbackSegmentsMarkRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + err = svc.Segments.SetManualMark(c.Request.Context(), m, service.ManualMarkRequest{ + Kind: req.Kind, + PositionMs: req.PositionMs, + Clear: req.Clear, + }) + if errors.Is(err, service.ErrInvalidManualMark) { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid skip mark"}) + return + } + if err != nil { + if svc.Log != nil { + svc.Log.Warn("save manual skip mark failed", + zap.String("media_id", m.ID), zap.Error(err)) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "save failed"}) + return + } + rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m) + if listErr != nil { + if svc.Log != nil { + svc.Log.Debug("list media segments after mark failed", + zap.String("media_id", m.ID), zap.Error(listErr)) + } + } + c.JSON(http.StatusOK, gin.H{ + "segments": service.ToSegmentViews(rows), + "auto_skip": resolveAutoSkipFlag(c, svc), + }) + } +} + // resolveAutoSkipFlag reads the「自动跳过片头」switch off whichever profile is // currently in effect. It reuses selectedPlayProfile so the server agrees with // the UI about which profile is active (explicit header first, then the user's diff --git a/internal/handler/routes_authenticated_extras.go b/internal/handler/routes_authenticated_extras.go index e638d69..274eb57 100644 --- a/internal/handler/routes_authenticated_extras.go +++ b/internal/handler/routes_authenticated_extras.go @@ -74,6 +74,7 @@ func registerAuthedPlaybackExtraRoutes(authed *gin.RouterGroup, svc *service.Con authed.GET("/playback/:id/info", playbackInfoHandler(svc)) authed.GET("/playback/:id/resume", playbackResumeHandler(svc)) authed.GET("/playback/:id/segments", playbackSegmentsHandler(svc)) + authed.PUT("/playback/:id/segments", playbackSegmentsMarkHandler(svc)) authed.POST("/playback/:id/progress", playbackProgressHandler(svc)) authed.GET("/playback/:id/external-players", externalPlayersHandler(svc)) authed.GET("/playback/:id/external-url", externalURLHandler(svc)) diff --git a/internal/repository/media_repository.go b/internal/repository/media_repository.go index 37f9a67..3375751 100644 --- a/internal/repository/media_repository.go +++ b/internal/repository/media_repository.go @@ -237,10 +237,16 @@ func (r *MediaRepository) ListSeasonSiblings(ctx context.Context, m *model.Media } else { libraryID := strings.TrimSpace(m.LibraryID) title := strings.TrimSpace(m.Title) - if libraryID == "" || title == "" || m.TMDbID <= 0 { + if libraryID == "" || title == "" { return nil, nil } - query = query.Where("library_id = ? AND title = ? AND tm_db_id = ?", libraryID, title, m.TMDbID) + // 没有 series_id 也没有 TMDb id 时,退到「同库 + 同标题」:手动标记的按季 + // 传播是用户显式操作,同库同名已经足够可靠;而在缺 id 时直接放弃会让这个 + // 功能在未刮削的库上完全不生效。 + query = query.Where("library_id = ? AND title = ?", libraryID, title) + if m.TMDbID > 0 { + query = query.Where("tm_db_id = ?", m.TMDbID) + } } var rows []model.Media if err := query.Find(&rows).Error; err != nil { diff --git a/internal/repository/media_segment_repository.go b/internal/repository/media_segment_repository.go index 7bd88c0..648f42b 100644 --- a/internal/repository/media_segment_repository.go +++ b/internal/repository/media_segment_repository.go @@ -56,6 +56,23 @@ func (r *MediaSegmentRepository) ReplaceForMedia(ctx context.Context, mediaID, s }) } +// ReplaceForMediaSourceKind swaps the segments of one (media, source, kind) +// triple in a single transaction. Used by manual marking, where the user sets +// one boundary at a time and must not disturb the other kinds. +func (r *MediaSegmentRepository) ReplaceForMediaSourceKind(ctx context.Context, mediaID, source, kind string, rows []model.MediaSegment) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Unscoped(). + Where("media_id = ? AND source = ? AND kind = ?", mediaID, source, kind). + Delete(&model.MediaSegment{}).Error; err != nil { + return err + } + if len(rows) == 0 { + return nil + } + return tx.Create(&rows).Error + }) +} + // GetFetch returns the fetch ledger row for (media, source), or (nil, nil). func (r *MediaSegmentRepository) GetFetch(ctx context.Context, mediaID, source string) (*model.MediaSegmentFetch, error) { var row model.MediaSegmentFetch diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 67cea80..8326f74 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -345,8 +345,24 @@ func (e *EmbyService) Items(ctx context.Context, p ItemsParams) (map[string]any, } } + // 客户端刷新首页时常用 Movie,Series,Video 这种混合类型一次取「最新」行。 + // 只有剧集库才该折叠成剧集卡片:seriesItemsForLibrary 只查 + // (season_num>0 OR episode_num>0),电影库走到这里会让整行返回 0 条。 if containsItemType(p.IncludeItemTypes, "Series") && !containsItemType(p.IncludeItemTypes, "Episode") { - return e.seriesItemsForLibrary(ctx, p.ParentID, p) + episodic := true + if p.ParentID != "" { + if value, err := e.libraryIsEpisodic(ctx, p.ParentID); err == nil { + episodic = value + } + } + if episodic { + return e.seriesItemsForLibrary(ctx, p.ParentID, p) + } + // 非剧集库:只请求 Series 时确实没有可返回的条目;混合类型则落回 + // mediaItems,由它按 Movie 过滤,而不是整行判空。 + if !containsItemType(p.IncludeItemTypes, "Movie") && !containsItemType(p.IncludeItemTypes, "Video") { + return emptyItemsEnvelope(p.StartIndex), nil + } } // 电影库的「常规浏览」(未指定 IncludeItemTypes): 电影库里偶尔混入按 diff --git a/internal/service/episode_metadata_cleanup.go b/internal/service/episode_metadata_cleanup.go index b069b1a..586d280 100644 --- a/internal/service/episode_metadata_cleanup.go +++ b/internal/service/episode_metadata_cleanup.go @@ -144,3 +144,43 @@ func (c *Container) NormalizePollutedEpisodeMetadata(ctx context.Context) (int, } return cleaned, nil } + +// segmentRangeCleanupSettingKey marks the one-time removal of impossible +// skip ranges written by the older end_ms: 0 interpretation. +const segmentRangeCleanupSettingKey = "segment.range_cleanup_v1_done" + +// NormalizeImpossibleSegments 删除历史误写进来的「不可能区间」。 +// +// 旧解析把 TheIntroDB 的 end_ms: 0 当成「延续到片尾」,于是 +// intro:[{start_ms:null,end_ms:0}] 被落成 intro (0, 0),客户端补齐后覆盖整集; +// 同理 recap (0,0) 也会让整集变成回顾。这类行只会让播放器一开播就弹跳过按钮, +// 没有任何保留价值,直接硬删。 +// +// 幂等:完成后写 setting 标记。只处理片头/回顾——片尾类允许开放结束点。 +func (c *Container) NormalizeImpossibleSegments(ctx context.Context) (int, error) { + if c == nil || c.Repo == nil || c.Repo.DB == nil { + return 0, nil + } + if c.Repo.Setting != nil { + if v, err := c.Repo.Setting.Get(ctx, segmentRangeCleanupSettingKey); err == nil && strings.TrimSpace(v) == "true" { + return 0, nil + } + } + res := c.Repo.DB.WithContext(ctx).Unscoped(). + Where("kind IN ?", []string{model.SegmentKindIntro, model.SegmentKindRecap}). + Where("end_ms <= start_ms"). + Delete(&model.MediaSegment{}) + if res.Error != nil { + return 0, res.Error + } + cleaned := int(res.RowsAffected) + if c.Repo.Setting != nil { + if err := c.Repo.Setting.Set(ctx, segmentRangeCleanupSettingKey, "true"); err != nil && c.Log != nil { + c.Log.Warn("mark segment range cleanup done failed", zap.Error(err)) + } + } + if cleaned > 0 && c.Log != nil { + c.Log.Info("impossible skip segments removed", zap.Int("rows", cleaned)) + } + return cleaned, nil +} diff --git a/internal/service/introdb.go b/internal/service/introdb.go index b777105..a9b515b 100644 --- a/internal/service/introdb.go +++ b/internal/service/introdb.go @@ -41,15 +41,6 @@ const ( introDBMaxRetryDelay = 3 * time.Second ) -// IntroDBSpan is one resolved skip range, still in provider terms. -// EndMs == 0 means "runs to the end of the media" (TheIntroDB returns -// end_ms: null for end credits); the caller resolves it against the duration. -type IntroDBSpan struct { - Kind string - StartMs int64 - EndMs int64 -} - // IntroDBService queries TheIntroDB for one media item. type IntroDBService struct { log *zap.Logger @@ -113,7 +104,7 @@ type introDBResponse struct { // 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方 // 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存, // 把「拿不到片段」维持在「少一个跳过按钮」的量级。 -func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) { +func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]SegmentSpan, error) { if s == nil || s.client == nil { return nil, errors.New("introdb service nil") } @@ -135,9 +126,20 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) } } +// Source implements SegmentProvider. +func (s *IntroDBService) Source() string { return IntroDBSource } + +// FetchSegments implements SegmentProvider. TheIntroDB 以 TMDb id 寻址。 +func (s *IntroDBService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) { + if q.IsMovie { + return s.Fetch(ctx, q.TMDbID, 0, 0) + } + return s.Fetch(ctx, q.TMDbID, q.Season, q.Episode) +} + // introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。 type introDBFetchAttempt struct { - spans []IntroDBSpan + spans []SegmentSpan err error retryable bool retryAfter time.Duration @@ -246,7 +248,7 @@ func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string { // parseIntroDBResponse flattens the per-type arrays into spans, preserving the // intro -> recap -> credits -> preview order so the player sees the earliest // range first. -func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) { +func parseIntroDBResponse(body []byte) ([]SegmentSpan, error) { var raw introDBResponse if err := json.Unmarshal(body, &raw); err != nil { return nil, fmt.Errorf("parse introdb json: %w", err) @@ -260,7 +262,7 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) { {"credits", raw.Credits}, {"preview", raw.Preview}, } - spans := make([]IntroDBSpan, 0, len(raw.Intro)+len(raw.Credits)) + spans := make([]SegmentSpan, 0, len(raw.Intro)+len(raw.Credits)) for _, group := range groups { for _, r := range group.ranges { var start int64 @@ -274,12 +276,24 @@ func parseIntroDBResponse(body []byte) ([]IntroDBSpan, error) { if start < 0 { start = 0 } - // end == 0 表示「延续到片尾」,是合法值;其余情况 end 必须大于 start, - // 否则这段区间没有任何可跳过的内容,直接丢弃避免在播放器里出现空按钮。 - if end != 0 && end <= start { + // 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)区分了两种 + // 「没有数值」的写法,含义完全不同,必须按指针区分而不能都归零: + // end_ms: null —— Credits/Preview 专用,「一直到片尾」,合法, + // 落成 0 作为哨兵值由客户端结合片长补齐; + // end_ms: 0 —— 「no segment」,即这一段不存在。 + // 早期实现把两者都当成「到片尾」,于是 intro:[{start_ms:null,end_ms:0}] + // 被解释成 [0, 片长],整集都落在「片头」区间里:一开播就弹跳过按钮, + // 开了自动跳过更是直接跳到片尾。实测约三分之一的剧集会返回这种占位。 + if r.EndMs != nil && end <= start { continue } - spans = append(spans, IntroDBSpan{Kind: group.kind, StartMs: start, EndMs: end}) + // 只有片尾类区间才允许「开放结束点」(end_ms: null 落成 0)。片头/回顾 + // 如果出现 null 结束点,补齐后的区间会覆盖到片尾,等于把整集判成片头, + // 所以宁可不显示也不猜。 + if r.EndMs == nil && group.kind != "credits" && group.kind != "preview" { + continue + } + spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end}) } } return spans, nil diff --git a/internal/service/introdb_app.go b/internal/service/introdb_app.go new file mode 100644 index 0000000..b55bf62 --- /dev/null +++ b/internal/service/introdb_app.go @@ -0,0 +1,160 @@ +// Package service — IntroDB (https://introdb.app) segment client. +// +// 与 TheIntroDB 是两家不同的社区库:IntroDB 只接受 IMDb id 定位媒体, +// 用 is_movie 区分电影与剧集,响应把每种区间直接给成 {start_ms,end_ms} 对象 +// (而不是按类型分数组),片尾字段名是 outro 而不是 credits, +// 另外还有 post_credits(片尾彩蛋,映射为预告/额外片尾)。 +package service + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "go.uber.org/zap" +) + +// IntroDBAppBaseURL 是公开只读端点。POST /submit 需要 API key,这里不做写入。 +const IntroDBAppBaseURL = "https://api.introdb.app" + +// IntroDBAppSource 写入 media_segments.source,与 TheIntroDB 的 "theintrodb" 区分。 +const IntroDBAppSource = "introdb" + +const ( + introDBAppTimeout = 8 * time.Second + introDBAppMaxBodySize = 1 << 20 +) + +// introDBAppSegment 是 IntroDB 的单个区间对象。null 表示该类型没有数据。 +type introDBAppSegment struct { + StartMs *int64 `json:"start_ms"` + EndMs *int64 `json:"end_ms"` +} + +// introDBAppResponse 对应 GET /segments。 +type introDBAppResponse struct { + IMDbID string `json:"imdb_id"` + MediaType string `json:"media_type"` + IsMovie bool `json:"is_movie"` + Season int `json:"season"` + Episode int `json:"episode"` + Intro *introDBAppSegment `json:"intro"` + Recap *introDBAppSegment `json:"recap"` + Outro *introDBAppSegment `json:"outro"` + PostCredits *introDBAppSegment `json:"post_credits"` +} + +// IntroDBAppService 查询 IntroDB 的片段数据。 +type IntroDBAppService struct { + log *zap.Logger + client *http.Client + baseURL string +} + +// NewIntroDBAppService is the constructor. The client honours environment and +// OS proxy settings so it behaves like the other third-party API clients. +func NewIntroDBAppService(log *zap.Logger) *IntroDBAppService { + return &IntroDBAppService{ + log: log, + client: NewExternalHTTPClient(introDBAppTimeout), + baseURL: IntroDBAppBaseURL, + } +} + +// SetBaseURL overrides the API root (tests, mirrors). +func (s *IntroDBAppService) SetBaseURL(base string) *IntroDBAppService { + if s != nil && strings.TrimSpace(base) != "" { + s.baseURL = strings.TrimRight(strings.TrimSpace(base), "/") + } + return s +} + +// Source implements SegmentProvider. +func (s *IntroDBAppService) Source() string { return IntroDBAppSource } + +// FetchSegments implements SegmentProvider. +// +// 没有 IMDb id 时直接返回空:IntroDB 不接受 TMDb id,用别的 id 拼请求只会拿到 +// 400,那会被误记成「查询失败」从而保留旧缓存、永远不再刷新。 +func (s *IntroDBAppService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) { + if s == nil || s.client == nil || strings.TrimSpace(q.IMDbID) == "" { + return nil, nil + } + endpoint := s.segmentsURL(q) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + // 404 = 库里没有这部片,是正常的「无数据」,不是错误。 + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, introDBAppMaxBodySize)) + if err != nil { + return nil, err + } + return parseIntroDBAppResponse(body) +} + +func (s *IntroDBAppService) segmentsURL(q SegmentQuery) string { + values := url.Values{} + values.Set("imdb_id", strings.TrimSpace(q.IMDbID)) + if q.IsMovie { + values.Set("is_movie", "true") + } else if q.Season > 0 && q.Episode > 0 { + values.Set("season", strconv.Itoa(q.Season)) + values.Set("episode", strconv.Itoa(q.Episode)) + } + return s.baseURL + "/segments?" + values.Encode() +} + +// parseIntroDBAppResponse 把 IntroDB 的四个区间对象摊平成 spans。 +// +// 顺序固定为 intro -> recap -> outro -> post_credits,与 TheIntroDB 的 +// intro/recap/credits/preview 对齐,后处理逻辑因此可以对来源无感。 +func parseIntroDBAppResponse(body []byte) ([]SegmentSpan, error) { + var raw introDBAppResponse + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse introdb json: %w", err) + } + groups := []struct { + kind string + seg *introDBAppSegment + }{ + {"intro", raw.Intro}, + {"recap", raw.Recap}, + {"credits", raw.Outro}, + {"preview", raw.PostCredits}, + } + spans := make([]SegmentSpan, 0, len(groups)) + for _, group := range groups { + if group.seg == nil || group.seg.StartMs == nil || group.seg.EndMs == nil { + continue + } + start, end := *group.seg.StartMs, *group.seg.EndMs + if start < 0 { + start = 0 + } + if end <= start { + continue + } + spans = append(spans, SegmentSpan{Kind: group.kind, StartMs: start, EndMs: end}) + } + return spans, nil +} diff --git a/internal/service/manual_mark_test.go b/internal/service/manual_mark_test.go new file mode 100644 index 0000000..dac7a26 --- /dev/null +++ b/internal/service/manual_mark_test.go @@ -0,0 +1,165 @@ +package service + +import ( + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +func newManualMarkFixture(t *testing.T) (*MediaSegmentService, *repository.Container, *model.Media) { + t.Helper() + repos := repository.New(newServiceTestDB(t)) + svc := NewMediaSegmentService(zap.NewNop(), repos) + m := &model.Media{ + Base: model.Base{ID: "ep-1"}, + Title: "Show", + Path: "/tv/show/s01e01.mkv", + LibraryID: "lib-1", + SeasonNum: 1, + EpisodeNum: 1, + } + if err := repos.DB.Create(m).Error; err != nil { + t.Fatal(err) + } + return svc, repos, m +} + +func TestSetManualMarkWritesIntroBoundary(t *testing.T) { + svc, repos, m := newManualMarkFixture(t) + if err := svc.SetManualMark(t.Context(), m, ManualMarkRequest{ + Kind: model.SegmentKindIntro, PositionMs: 90_000, + }); err != nil { + t.Fatalf("set mark: %v", err) + } + rows, err := repos.MediaSegment.ListByMedia(t.Context(), m.ID) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("rows = %#v, want 1", rows) + } + if rows[0].Kind != model.SegmentKindIntro || rows[0].Source != SegmentSourceManual { + t.Fatalf("row = %#v, want manual intro", rows[0]) + } + if rows[0].StartMs != 0 || rows[0].EndMs != 90_000 { + t.Fatalf("row = %#v, want 0-90000ms", rows[0]) + } +} + +func TestSetManualMarkClearRemovesOnlyThatKind(t *testing.T) { + svc, repos, m := newManualMarkFixture(t) + ctx := t.Context() + if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, PositionMs: 90_000}); err != nil { + t.Fatal(err) + } + if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindCredits, PositionMs: 1_200_000}); err != nil { + t.Fatal(err) + } + if err := svc.SetManualMark(ctx, m, ManualMarkRequest{Kind: model.SegmentKindIntro, Clear: true}); err != nil { + t.Fatal(err) + } + rows, err := repos.MediaSegment.ListByMedia(ctx, m.ID) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Kind != model.SegmentKindCredits { + t.Fatalf("rows = %#v, want only the credits mark to survive", rows) + } +} + +func TestSetManualMarkRejectsInvalidRequests(t *testing.T) { + svc, _, m := newManualMarkFixture(t) + cases := []ManualMarkRequest{ + {Kind: "recap", PositionMs: 1000}, // 只支持 intro/credits + {Kind: "preview", PositionMs: 1000}, // 同上 + {Kind: model.SegmentKindIntro, PositionMs: 0}, + {Kind: model.SegmentKindIntro, PositionMs: -5}, + } + for _, req := range cases { + if err := svc.SetManualMark(t.Context(), m, req); err == nil { + t.Fatalf("req %#v: want an error", req) + } + } +} + +// 同一季的其它集应自动继承手动标记,但用户已显式标过的集不能被覆盖。 +func TestSetManualMarkPropagatesToSeasonSiblings(t *testing.T) { + svc, repos, m := newManualMarkFixture(t) + ctx := t.Context() + sibling := &model.Media{ + Base: model.Base{ID: "ep-2"}, + Title: "Show", + Path: "/tv/show/s01e02.mkv", + LibraryID: "lib-1", + SeasonNum: 1, + EpisodeNum: 2, + } + other := &model.Media{ + Base: model.Base{ID: "ep-3"}, + Title: "Show", + Path: "/tv/show/s01e03.mkv", + LibraryID: "lib-1", + SeasonNum: 1, + EpisodeNum: 3, + } + if err := repos.DB.Create(sibling).Error; err != nil { + t.Fatal(err) + } + if err := repos.DB.Create(other).Error; err != nil { + t.Fatal(err) + } + // other 已经有自己的手动片头标记,不该被覆盖。 + if err := svc.SetManualMark(ctx, other, ManualMarkRequest{ + Kind: model.SegmentKindIntro, PositionMs: 45_000, + }); err != nil { + t.Fatal(err) + } + + if err := svc.SetManualMark(ctx, m, ManualMarkRequest{ + Kind: model.SegmentKindIntro, PositionMs: 90_000, + }); err != nil { + t.Fatal(err) + } + + sibRows, err := repos.MediaSegment.ListByMedia(ctx, sibling.ID) + if err != nil { + t.Fatal(err) + } + if len(sibRows) != 1 || sibRows[0].EndMs != 90_000 { + t.Fatalf("sibling rows = %#v, want the propagated 90000ms mark", sibRows) + } + + otherRows, err := repos.MediaSegment.ListByMedia(ctx, other.ID) + if err != nil { + t.Fatal(err) + } + if len(otherRows) != 1 || otherRows[0].EndMs != 45_000 { + t.Fatalf("other rows = %#v, want its own 45000ms mark preserved", otherRows) + } +} + +// 手动标记优先级最高:即使外部源给了不同位置,播放时也只输出手动那条。 +func TestManualMarkOutranksExternalSource(t *testing.T) { + svc, repos, m := newManualMarkFixture(t) + ctx := t.Context() + if err := repos.MediaSegment.ReplaceForMedia(ctx, m.ID, IntroDBSource, []model.MediaSegment{{ + MediaID: m.ID, Kind: model.SegmentKindIntro, StartMs: 0, EndMs: 30_000, Source: IntroDBSource, + }}); err != nil { + t.Fatal(err) + } + if err := svc.SetManualMark(ctx, m, ManualMarkRequest{ + Kind: model.SegmentKindIntro, PositionMs: 90_000, + }); err != nil { + t.Fatal(err) + } + rows, err := svc.mergeForPlayback(ctx, m.ID) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 || rows[0].Source != SegmentSourceManual || rows[0].EndMs != 90_000 { + t.Fatalf("rows = %#v, want the manual mark to win", rows) + } +} diff --git a/internal/service/media_segment.go b/internal/service/media_segment.go index b345fb3..ace7d9a 100644 --- a/internal/service/media_segment.go +++ b/internal/service/media_segment.go @@ -6,6 +6,7 @@ package service import ( "context" + "errors" "strings" "time" @@ -43,9 +44,20 @@ type SegmentView struct { } // ToSegmentViews 转换库内行为对外视图。 +// +// 顺手丢掉明显不可能的区间:库里的历史数据可能是在 end_ms: 0 被误当成「到片尾」 +// 的时期写下的(那会把整集变成片头),这些行如果原样下发,播放器仍然会整集弹 +// 跳过按钮。只有片尾类允许开放结束点(EndMs == 0),其余要求 EndMs > StartMs。 func ToSegmentViews(rows []model.MediaSegment) []SegmentView { out := make([]SegmentView, 0, len(rows)) for _, row := range rows { + openEnded := row.Kind == model.SegmentKindCredits || row.Kind == model.SegmentKindPreview + if row.StartMs < 0 { + continue + } + if row.EndMs <= row.StartMs && !(openEnded && row.EndMs <= 0) { + continue + } out = append(out, SegmentView{Kind: row.Kind, StartMs: row.StartMs, EndMs: row.EndMs}) } return out @@ -53,16 +65,36 @@ func ToSegmentViews(rows []model.MediaSegment) []SegmentView { // MediaSegmentService 负责把外部片头片尾数据补齐到本地并供播放器读取。 type MediaSegmentService struct { - log *zap.Logger - repo *repository.Container - introdb *IntroDBService - // probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用 TheIntroDB。 + log *zap.Logger + repo *repository.Container + // probe 负责异步补齐媒体信息(主要是 STRM 时长)。未注入时只用外部源。 probe *MediaProbeService - // prewarmGap 是预热两次 IntroDB 请求之间的间隔;测试可设为 0。 + // prewarmGap 是预热两次请求之间的间隔;测试可设为 0。 prewarmGap time.Duration now func() time.Time + // providers 是已启用的外部片段源,顺序即注册顺序。 + // 由设置项 segment.source 决定实际使用哪一个,见 activeProvider。 + providers []SegmentProvider + // imdb 用于给只认 IMDb id 的源(IntroDB)解析外部 id。未注入时该源被跳过。 + imdb IMDbIDResolver } +// IMDbIDResolver 把 TMDb 条目解析成 IMDb id(ttXXXXXXX)。 +// 由 TMDbProvider 实现;抽出接口是为了让 service 层不直接依赖配置与 API key。 +type IMDbIDResolver interface { + ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error) +} + +// 片段数据源设置项。取值形如 off / theintrodb / introdb / todb, +// 与播放器档案里的「自动跳过」是两件独立的事。 +const SegmentSourceSettingKey = "segment.source" + +// SegmentSourceOff 表示不查询任何外部源,只消费已落库的数据(手动标记等)。 +const SegmentSourceOff = "off" + +// SegmentSourceOptions 是设置页可选的源标识。 +var SegmentSourceOptions = []string{SegmentSourceOff, IntroDBSource, IntroDBAppSource, TODbSource} + // NewMediaSegmentService is the constructor. func NewMediaSegmentService(log *zap.Logger, repo *repository.Container) *MediaSegmentService { return &MediaSegmentService{ @@ -81,14 +113,98 @@ func (s *MediaSegmentService) SetPrewarmGap(gap time.Duration) *MediaSegmentServ return s } -// SetIntroDB wires the provider. Without it the service only reads cached rows. +// SetIntroDB wires the TheIntroDB provider and registers it as a source. func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService { if s != nil { - s.introdb = p + s.registerProvider(p) } return s } +// SetIntroDBApp wires the IntroDB (introdb.app) provider. +func (s *MediaSegmentService) SetIntroDBApp(p *IntroDBAppService) *MediaSegmentService { + if s != nil { + s.registerProvider(p) + } + return s +} + +// SetTODb wires the TODb provider. +func (s *MediaSegmentService) SetTODb(p *TODbService) *MediaSegmentService { + if s != nil { + s.registerProvider(p) + } + return s +} + +// SetIMDbResolver wires the TMDb-backed IMDb id resolver used by sources that +// only address media by IMDb id. +func (s *MediaSegmentService) SetIMDbResolver(r IMDbIDResolver) *MediaSegmentService { + if s != nil { + s.imdb = r + } + return s +} + +// registerProvider adds a provider once, keeping registration order as the +// fallback order. +func (s *MediaSegmentService) registerProvider(p SegmentProvider) { + if s == nil || p == nil { + return + } + for _, existing := range s.providers { + if existing.Source() == p.Source() { + return + } + } + s.providers = append(s.providers, p) +} + +// providerBySource returns the registered provider with that source tag. +func (s *MediaSegmentService) providerBySource(source string) SegmentProvider { + for _, p := range s.providers { + if p.Source() == source { + return p + } + } + return nil +} + +// activeProvider 选出本次查询要用的源。 +// +// 设置项没配置时沿用历史行为(TheIntroDB),避免升级后功能凭空消失; +// 显式配置成 off 才真正停用外部查询。 +func (s *MediaSegmentService) activeProvider(ctx context.Context) SegmentProvider { + if s == nil || len(s.providers) == 0 { + return nil + } + source := s.configuredSource(ctx) + if source == "" { + return s.providerBySource(IntroDBSource) + } + if source == SegmentSourceOff { + return nil + } + if p := s.providerBySource(source); p != nil { + return p + } + // 配了一个不认识的源(多半是降级回滚后的残留设置):退回默认源, + // 让功能继续可用,而不是静默失效。 + return s.providerBySource(IntroDBSource) +} + +// configuredSource reads the operator's source selection. +func (s *MediaSegmentService) configuredSource(ctx context.Context) string { + if s == nil || s.repo == nil || s.repo.Setting == nil { + return "" + } + v, err := s.repo.Setting.Get(ctx, SegmentSourceSettingKey) + if err != nil { + return "" + } + return strings.ToLower(strings.TrimSpace(v)) +} + // SetProbe wires the async media probe (duration backfill for STRM). Without it // open-ended credits still work once duration is known from elsewhere. func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService { @@ -114,7 +230,7 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi // 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是 // SQLITE_BUSY,实测能直接把社区库的落库打失败)。 defer s.ensureMediaProbe(ctx, m) - return s.introDBSegments(ctx, m) + return s.communitySegments(ctx, m) } // ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。 @@ -147,10 +263,15 @@ func mediaProbeSettled(row *model.MediaProbe) bool { return time.Since(row.ProbedAt) < mediaProbeFailureRetry } -// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。 -// 返回值是按来源优先级合并后的结果(manual > theintrodb > propagated)。 -func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) { - ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, IntroDBSource) +// communitySegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。 +// 返回值是按来源优先级合并后的结果(manual > 外部源 > propagated)。 +func (s *MediaSegmentService) communitySegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) { + provider := s.activeProvider(ctx) + // 没选任何外部源(off):只读本地已有数据,绝不发起外网请求。 + if provider == nil { + return s.mergeForPlayback(context.WithoutCancel(ctx), m.ID) + } + ledger, err := s.repo.MediaSegment.GetFetch(ctx, m.ID, provider.Source()) if err != nil { // 调用方预算耗尽时仍读本地缓存,绝不能让播放接口报错。 if ctx.Err() != nil { @@ -159,7 +280,7 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi return nil, err } if ledger == nil || !ledgerFresh(ledger) { - if _, _, err := s.refresh(ctx, m); err != nil { + if _, _, err := s.refreshWithProvider(ctx, m, provider); err != nil { // 社区库不可达或返回异常:沿用已有缓存,不影响播放;也不写负缓存。 logIntroDBFailure(s.log, 0, err) } @@ -178,13 +299,22 @@ func (s *MediaSegmentService) mergeForPlayback(ctx context.Context, mediaID stri } // segmentSourcePriority 数值越大越优先。未知来源视为最低,避免挡住已知源。 +// +// 顺序体现用户意图的强度: +// +// manual 用户在当前集显式标记 +// propagated 用户在同季其它集标记后继承来的值(仍然是人给的,优先于社区库) +// 外部社区库 自动抓取,最容易被覆盖 +// +// 外部社区库之间刻意同级:用户一次只会启用其中一个,历史数据可能来自另一个, +// 同级时按区间起点排序展示即可,不必让旧源压住新源。 func segmentSourcePriority(source string) int { switch source { case SegmentSourceManual: return 3 - case IntroDBSource: - return 2 case SegmentSourcePropagated: + return 2 + case IntroDBSource, IntroDBAppSource, TODbSource: return 1 default: return 0 @@ -224,16 +354,17 @@ func (s *MediaSegmentService) debug(message, mediaID string, err error) { s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err)) } -// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。 +// refreshWithProvider 向指定来源查询并落库,返回 (rows, 是否真的发起过查询, error)。 // // attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削, -// 剧集也还没关联到 Series),此时刻意不写负缓存:等元数据补齐后下次播放就能查到。 -func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) { - if s.introdb == nil { +// 剧集也还没关联到 Series),或者该源需要的 id 类型拿不到(IntroDB 要 IMDb), +// 此时刻意不写负缓存:等元数据补齐后下次播放就能查到。 +func (s *MediaSegmentService) refreshWithProvider(ctx context.Context, m *model.Media, provider SegmentProvider) ([]model.MediaSegment, bool, error) { + if s == nil || provider == nil || m == nil { return nil, false, nil } - tmdbID, season, episode := s.queryIDs(ctx, m) - if tmdbID <= 0 { + query, ok := s.segmentQuery(ctx, m, provider) + if !ok { return nil, false, nil } // 用脱离请求的 context:客户端可能在抓取完成前就离开了播放页,但结果仍然 @@ -256,10 +387,11 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), budget) defer cancel() - spans, err := s.introdb.Fetch(fetchCtx, tmdbID, season, episode) + spans, err := provider.FetchSegments(fetchCtx, query) if err != nil { return nil, true, err } + source := provider.Source() rows := make([]model.MediaSegment, 0, len(spans)) for _, span := range spans { rows = append(rows, model.MediaSegment{ @@ -268,15 +400,15 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo Kind: span.Kind, StartMs: span.StartMs, EndMs: span.EndMs, - Source: IntroDBSource, + Source: source, }) } - if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, IntroDBSource, rows); err != nil { + if err := s.repo.MediaSegment.ReplaceForMedia(fetchCtx, m.ID, source, rows); err != nil { return nil, true, err } if err := s.repo.MediaSegment.UpsertFetch(fetchCtx, &model.MediaSegmentFetch{ MediaID: m.ID, - Source: IntroDBSource, + Source: source, FetchedAt: time.Now(), Found: len(rows) > 0, }); err != nil { @@ -286,6 +418,38 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo return rows, true, nil } +// refresh 保留旧签名(预热任务与测试使用),走当前选中的源。 +func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool, error) { + return s.refreshWithProvider(ctx, m, s.activeProvider(ctx)) +} + +// segmentQuery 组装该源需要的身份参数。返回 false 表示这个源缺少必要 ID, +// 本次不必发请求。 +func (s *MediaSegmentService) segmentQuery(ctx context.Context, m *model.Media, provider SegmentProvider) (SegmentQuery, bool) { + tmdbID, season, episode := s.queryIDs(ctx, m) + isMovie := season <= 0 && episode <= 0 + q := SegmentQuery{TMDbID: tmdbID, Season: season, Episode: episode, IsMovie: isMovie} + + switch provider.Source() { + case IntroDBAppSource: + // IntroDB 只认 IMDb id,必须经 TMDb external_ids 解析。 + if s.imdb == nil || tmdbID <= 0 { + return q, false + } + imdbID, err := s.imdb.ResolveIMDbID(ctx, tmdbID, isMovie) + if err != nil || imdbID == "" { + // 解析不出 IMDb id 不是错误:这部片可能本来就没有,或 TMDb 不可达。 + // 返回 false 让本次跳过,但不写负缓存,下次还有机会。 + s.debug("resolve imdb id failed", m.ID, err) + return q, false + } + q.IMDbID = imdbID + return q, true + default: + return q, tmdbID > 0 + } +} + // propagateIntroToSeason 把本集 IntroDB 命中的 intro 复制到同季还没有 // theintrodb intro 的兄弟集。片尾/预告不传播:各集时长与片尾位置经常不同。 func (s *MediaSegmentService) propagateIntroToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) { @@ -347,9 +511,131 @@ func hasSegmentKind(rows []model.MediaSegment, kind string) bool { return false } -// Prewarm 批量向 TheIntroDB 补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。 +// ManualMarkRequest 是「标记片头/片尾」的入参。 +// +// PositionMs 是用户点击时所在的播放位置:标记片头时它是片头结束点, +// 标记片尾时它是片尾开始点。 +type ManualMarkRequest struct { + Kind string + PositionMs int64 + // Clear 为 true 表示清除该类型的标记,PositionMs 被忽略。 + Clear bool +} + +// ErrInvalidManualMark 表示请求本身不合法(类型的取值或位置越界)。 +var ErrInvalidManualMark = errors.New("invalid manual skip mark") + +// SetManualMark 写入或清除一条手动标记,并按季传播片头/片尾。 +// +// 手动标记的优先级最高(见 segmentSourcePriority),所以用户标过一次之后, +// 外部源即使给出不同位置也不会覆盖它。清除时只删 manual 来源的行,外部数据 +// 会自然回落到优先队列里。 +func (s *MediaSegmentService) SetManualMark(ctx context.Context, m *model.Media, req ManualMarkRequest) error { + if s == nil || s.repo == nil || m == nil || m.ID == "" { + return ErrInvalidManualMark + } + kind := normalizeManualMarkKind(req.Kind) + if kind == "" { + return ErrInvalidManualMark + } + if !req.Clear && req.PositionMs < 0 { + return ErrInvalidManualMark + } + rows := []model.MediaSegment{} + if !req.Clear { + if req.PositionMs <= 0 { + // 位置 0 的标记没有意义:片头在 0 秒结束等于没有片头。 + return ErrInvalidManualMark + } + rows = append(rows, model.MediaSegment{ + MediaID: m.ID, + SeriesID: m.SeriesID, + Kind: kind, + StartMs: manualMarkStart(kind), + EndMs: req.PositionMs, + Source: SegmentSourceManual, + }) + } + if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, m.ID, SegmentSourceManual, kind, rows); err != nil { + return err + } + if len(rows) > 0 { + s.propagateManualMarkToSeason(ctx, m, rows) + } + return nil +} + +// normalizeManualMarkKind 把外部传入的类型收敛到库内 kind。 +// 只接受片头与片尾:回顾/预告没有对应的用户操作入口。 +func normalizeManualMarkKind(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case model.SegmentKindIntro: + return model.SegmentKindIntro + case model.SegmentKindCredits: + return model.SegmentKindCredits + default: + return "" + } +} + +// manualMarkStart 给出该类型区间的起点:片头从 0 开始,片尾的开始点就是用户 +// 标记的位置(所以这里填 0,真正的起点在 EndMs 里由调用方传入)。 +func manualMarkStart(kind string) int64 { return 0 } + +// propagateManualMarkToSeason 把手动标记复制到同季还没有「显式手动标记」的兄弟集。 +// +// 同一季的 OP/ED 通常一致,标一集即可覆盖整季——这是报告里最实用的行为。 +// 关键约束:传播值必须写成 SegmentSourcePropagated 而不是 manual,这样才能区分 +// 「用户显式标过的集」与「继承来的值」。否则先标 A 集会让 B 集看起来也是手动标 +// 过的,之后用户再标 B 集时反而无法覆盖它(或反过来,B 集的传播值挡住 A 集的 +// 新标记),两种顺序都会得出错误结果。 +func (s *MediaSegmentService) propagateManualMarkToSeason(ctx context.Context, m *model.Media, rows []model.MediaSegment) { + if s == nil || s.repo == nil || m == nil || m.SeasonNum <= 0 || len(rows) == 0 { + return + } + siblings, err := s.repo.Media.ListSeasonSiblings(ctx, m) + if err != nil { + s.debug("list season siblings for manual mark failed", m.ID, err) + return + } + for i := range siblings { + sib := &siblings[i] + // 用户显式标过的集保持原样;继承值可以被新的显式标记覆盖。 + explicit, err := s.repo.MediaSegment.ListByMediaSource(ctx, sib.ID, SegmentSourceManual) + if err != nil { + s.debug("list sibling manual segments failed", sib.ID, err) + continue + } + if hasSegmentKind(explicit, rows[0].Kind) { + continue + } + copied := make([]model.MediaSegment, 0, len(rows)) + for _, row := range rows { + copied = append(copied, model.MediaSegment{ + MediaID: sib.ID, + SeriesID: sib.SeriesID, + Kind: row.Kind, + StartMs: row.StartMs, + EndMs: row.EndMs, + Source: SegmentSourcePropagated, + }) + } + if err := s.repo.MediaSegment.ReplaceForMediaSourceKind(ctx, sib.ID, SegmentSourcePropagated, rows[0].Kind, copied); err != nil { + s.debug("propagate manual mark failed", sib.ID, err) + } + } +} + +// Prewarm 批量补齐过期/缺失账本的可查询媒体。返回实际发起过查询的数量。 +// +// 只预热当前启用的源:用户把源切成 off 时不该继续打外网,切换源之后旧源的 +// 账本也不再需要刷新。 func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, error) { - if s == nil || s.repo == nil || s.introdb == nil { + if s == nil || s.repo == nil || len(s.providers) == 0 { + return 0, nil + } + provider := s.activeProvider(ctx) + if provider == nil { return 0, nil } if limit <= 0 { @@ -360,7 +646,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro now = s.now() } candidates, err := s.repo.MediaSegment.ListPrewarmCandidates( - ctx, IntroDBSource, now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit, + ctx, provider.Source(), now.Add(-segmentMissingTTL), now.Add(-segmentFoundTTL), limit, ) if err != nil { return 0, err @@ -371,7 +657,7 @@ func (s *MediaSegmentService) Prewarm(ctx context.Context, limit int) (int, erro return attempted, err } m := &candidates[i] - _, did, err := s.refresh(ctx, m) + _, did, err := s.refreshWithProvider(ctx, m, provider) if err != nil { logIntroDBFailure(s.log, m.TMDbID, err) } diff --git a/internal/service/media_segment_test.go b/internal/service/media_segment_test.go index 6cf78f3..bd52656 100644 --- a/internal/service/media_segment_test.go +++ b/internal/service/media_segment_test.go @@ -385,7 +385,9 @@ func TestListForPlaybackRespectsCallerDeadline(t *testing.T) { } } -func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing.T) { +// 优先级体现「用户意图强度」:manual > propagated > 社区库。 +// 传播值来自用户在同季其它集的手动标记,是人给的,必须压过自动抓取的数据。 +func TestPreferSegmentsBySourcePrefersManualThenPropagatedThenIntroDB(t *testing.T) { rows := []model.MediaSegment{ {Kind: model.SegmentKindIntro, StartMs: 1, EndMs: 2, Source: SegmentSourcePropagated}, {Kind: model.SegmentKindIntro, StartMs: 10, EndMs: 20, Source: IntroDBSource}, @@ -395,13 +397,14 @@ func TestPreferSegmentsBySourcePrefersManualThenIntroDBThenPropagated(t *testing } got := preferSegmentsBySource(rows) if len(got) != 2 { - t.Fatalf("got %#v, want manual intro + introdb credits", got) + t.Fatalf("got %#v, want manual intro + propagated credits", got) } if got[0].Source != SegmentSourceManual || got[0].StartMs != 100 { t.Fatalf("intro = %#v, want manual", got[0]) } - if got[1].Source != IntroDBSource || got[1].StartMs != 2000 { - t.Fatalf("credits = %#v, want theintrodb", got[1]) + // 片尾没有 manual,propagated 应当压过社区库。 + if got[1].Source != SegmentSourcePropagated || got[1].StartMs != 1000 { + t.Fatalf("credits = %#v, want propagated", got[1]) } } diff --git a/internal/service/segment_provider.go b/internal/service/segment_provider.go new file mode 100644 index 0000000..6b37082 --- /dev/null +++ b/internal/service/segment_provider.go @@ -0,0 +1,46 @@ +// Package service — 片头/片尾片段的外部提供方抽象。 +// +// 目前有三个社区库可用,它们对「媒体身份」的要求各不相同: +// +// TheIntroDB TMDb id(服务端自己映射到 TVDB) +// IntroDB 只有 IMDb id(用 is_movie 区分电影/剧集) +// TODb TMDb id + 平台类型(tmdb_id_tv / tmdb_id_movie),秒级时间轴 +// +// 播放链路只关心「一段可跳过的区间」,所以这里把三者收敛成同一个 +// SegmentProvider 接口和 SegmentQuery 入参,上层按设置项挑选其一。 +package service + +import "context" + +// SegmentSpan 是一个已解析的可跳过区间,单位为毫秒。 +// +// EndMs == 0 是哨兵值,表示「一直到片尾」:只有片尾(credits/preview)可能 +// 出现这种区间,由客户端结合媒体总时长补齐。片头/回顾不允许开放区间, +// 拿不到结束点就整段丢弃——否则会得到一个覆盖全片的区间。 +type SegmentSpan struct { + Kind string + StartMs int64 + EndMs int64 +} + +// IntroDBSpan 保留旧名字,避免既有调用方和测试大范围改名。 +type IntroDBSpan = SegmentSpan + +// SegmentQuery 是一次片段查询的身份参数。不同提供方按需取用其中一部分。 +type SegmentQuery struct { + TMDbID int + IMDbID string + Season int + Episode int + // IsMovie 为 true 时 Season/Episode 无意义。 + IsMovie bool +} + +// SegmentProvider 是一个外部片段数据源。 +type SegmentProvider interface { + // Source 返回写入 media_segments.source 的标识,也是缓存账本的键。 + Source() string + // FetchSegments 返回该媒体已知的可跳过区间。查不到不是错误(返回空切片), + // 只有请求本身失败才返回 error——调用方据此决定是否保留旧缓存。 + FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) +} diff --git a/internal/service/segment_providers_test.go b/internal/service/segment_providers_test.go new file mode 100644 index 0000000..9a71256 --- /dev/null +++ b/internal/service/segment_providers_test.go @@ -0,0 +1,150 @@ +package service + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "go.uber.org/zap" +) + +// 官方规范(theintrodb.org/openapi.yaml,SegmentTimestamp)对「没有数值」有两种 +// 写法,含义完全不同:end_ms: null = 一直到片尾;end_ms: 0 = 这一段不存在。 +// 实测约三分之一的剧集会返回 {"start_ms":null,"end_ms":0} 的 intro 占位。 +const ( + // 真实返回:intro 是占位、credits 到片尾。 + introDBNoSegmentPayload = `{"tvdb_id":73762,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":null,"end_ms":0}],"credits":[{"start_ms":2574000,"end_ms":null}]}` + // 整集都是占位:recap/credits 都是 (0,0)。 + introDBAllPlaceholderPayload = `{"tvdb_id":375921,"type":"tv","season":1,"episode":1,"recap":[{"start_ms":0,"end_ms":0}],"credits":[{"start_ms":0,"end_ms":0}]}` +) + +// 把 end_ms: 0 当成「到片尾」会让 intro 变成 [0, 片长],整集都是片头: +// 一开播就弹跳过按钮,开了自动跳过直接跳到片尾。 +func TestParseIntroDBResponseDropsZeroEndPlaceholders(t *testing.T) { + spans, err := parseIntroDBResponse([]byte(introDBNoSegmentPayload)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(spans) != 1 { + t.Fatalf("spans = %#v, want only the credits range", spans) + } + if spans[0].Kind != "credits" || spans[0].StartMs != 2_574_000 || spans[0].EndMs != 0 { + t.Fatalf("credits span = %#v, want start 2574000 and open end", spans[0]) + } +} + +func TestParseIntroDBResponseDropsAllPlaceholderSegments(t *testing.T) { + spans, err := parseIntroDBResponse([]byte(introDBAllPlaceholderPayload)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(spans) != 0 { + t.Fatalf("spans = %#v, want none for all-placeholder payload", spans) + } +} + +// 片头/回顾不允许开放结束点:即使数据源给了 null,也不能补齐成「一直到片尾」。 +func TestParseIntroDBResponseRejectsOpenEndedIntro(t *testing.T) { + body := `{"tvdb_id":1,"type":"tv","season":1,"episode":1,"intro":[{"start_ms":1000,"end_ms":null}]}` + spans, err := parseIntroDBResponse([]byte(body)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(spans) != 0 { + t.Fatalf("spans = %#v, want an open-ended intro to be dropped", spans) + } +} + +// IntroDB 的响应把区间给成对象而不是数组,且片尾字段叫 outro。 +func TestParseIntroDBAppResponseMapsKinds(t *testing.T) { + body := `{"imdb_id":"tt0903747","media_type":"tv","is_movie":false,"season":1,"episode":1, + "intro":{"start_ms":437000,"end_ms":531000}, + "recap":null, + "outro":{"start_ms":3431000,"end_ms":3500000}, + "post_credits":{"start_ms":3600000,"end_ms":3610000}}` + spans, err := parseIntroDBAppResponse([]byte(body)) + if err != nil { + t.Fatalf("parse: %v", err) + } + want := []struct { + kind string + start int64 + end int64 + }{ + {"intro", 437_000, 531_000}, + {"credits", 3_431_000, 3_500_000}, + {"preview", 3_600_000, 3_610_000}, + } + if len(spans) != len(want) { + t.Fatalf("spans = %#v, want %d", spans, len(want)) + } + for i, w := range want { + if spans[i].Kind != w.kind || spans[i].StartMs != w.start || spans[i].EndMs != w.end { + t.Fatalf("span[%d] = %#v, want %s %d-%d", i, spans[i], w.kind, w.start, w.end) + } + } +} + +// IntroDB 只认 IMDb id:没有 IMDb id 时不能退化成用 TMDb id 拼请求, +// 那只会在服务端拿到 400,被误记成「查询失败」而保留旧缓存。 +func TestIntroDBAppFetchSkipsWithoutIMDbID(t *testing.T) { + var calls int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL) + spans, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 1396, Season: 1, Episode: 1}) + if err != nil { + t.Fatalf("fetch: %v", err) + } + if len(spans) != 0 || calls != 0 { + t.Fatalf("spans = %#v calls = %d, want no request without an imdb id", spans, calls) + } +} + +func TestIntroDBAppFetchSendsIMDbAndSeasonEpisode(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"imdb_id":"tt0903747","media_type":"tv","is_movie":false, + "intro":{"start_ms":1000,"end_ms":2000}}`)) + })) + defer server.Close() + + svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL) + if _, err := svc.FetchSegments(t.Context(), SegmentQuery{ + IMDbID: "tt0903747", Season: 2, Episode: 3, + }); err != nil { + t.Fatalf("fetch: %v", err) + } + for _, want := range []string{"imdb_id=tt0903747", "season=2", "episode=3"} { + if !strings.Contains(gotQuery, want) { + t.Fatalf("query = %q, want it to contain %q", gotQuery, want) + } + } +} + +// 电影要用 is_movie,且不能带季集参数。 +func TestIntroDBAppFetchUsesIsMovieForMovies(t *testing.T) { + var gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"imdb_id":"tt1375666","is_movie":true}`)) + })) + defer server.Close() + + svc := NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL) + if _, err := svc.FetchSegments(t.Context(), SegmentQuery{IMDbID: "tt1375666", IsMovie: true}); err != nil { + t.Fatalf("fetch: %v", err) + } + if !strings.Contains(gotQuery, "is_movie=true") { + t.Fatalf("query = %q, want is_movie=true", gotQuery) + } + if strings.Contains(gotQuery, "season=") || strings.Contains(gotQuery, "episode=") { + t.Fatalf("query = %q, want no season/episode for a movie", gotQuery) + } +} diff --git a/internal/service/segment_source_test.go b/internal/service/segment_source_test.go new file mode 100644 index 0000000..f94a0d8 --- /dev/null +++ b/internal/service/segment_source_test.go @@ -0,0 +1,188 @@ +package service + +import ( + "context" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// stubIMDbResolver 返回固定 IMDb id,用于验证 IntroDB 的 id 解析链路。 +type stubIMDbResolver struct { + id string + calls int32 +} + +func (s *stubIMDbResolver) ResolveIMDbID(_ context.Context, _ int, _ bool) (string, error) { + atomic.AddInt32(&s.calls, 1) + return s.id, nil +} + +func TestSegmentSourceOffSkipsExternalLookup(t *testing.T) { + repos := repository.New(newServiceTestDB(t)) + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + _, _ = w.Write([]byte(introDBMoviePayload)) + })) + defer server.Close() + + svc := NewMediaSegmentService(zap.NewNop(), repos). + SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)) + if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, SegmentSourceOff); err != nil { + t.Fatal(err) + } + m := &model.Media{Base: model.Base{ID: "mv-off"}, Path: "/movies/off.mkv", TMDbID: 27205} + if err := repos.DB.Create(m).Error; err != nil { + t.Fatal(err) + } + + rows, err := svc.ListForPlayback(t.Context(), m) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 { + t.Fatalf("rows = %#v calls = %d, want no lookup when the source is off", rows, calls) + } +} + +// 默认(未配置设置项)必须保持历史行为:用 TheIntroDB。 +func TestSegmentSourceDefaultsToTheIntroDB(t *testing.T) { + repos := repository.New(newServiceTestDB(t)) + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + _, _ = w.Write([]byte(introDBMoviePayload)) + })) + defer server.Close() + + svc := NewMediaSegmentService(zap.NewNop(), repos). + SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)) + m := &model.Media{Base: model.Base{ID: "mv-default"}, Path: "/movies/default.mkv", TMDbID: 27205} + if err := repos.DB.Create(m).Error; err != nil { + t.Fatal(err) + } + + rows, err := svc.ListForPlayback(t.Context(), m) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 1 || atomic.LoadInt32(&calls) != 1 { + t.Fatalf("rows = %#v calls = %d, want the default provider to run", rows, calls) + } +} + +// 切到 TODb 后,TheIntroDB 不应再被调用,写入的 source 也要跟着变。 +func TestSegmentSourceSwitchesToTODb(t *testing.T) { + repos := repository.New(newServiceTestDB(t)) + var introCalls, todbCalls int32 + introServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&introCalls, 1) + _, _ = w.Write([]byte(introDBMoviePayload)) + })) + defer introServer.Close() + todbServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&todbCalls, 1) + _, _ = w.Write([]byte(`{"items":[{"marker":[{"marker_type":"intro","time_start":5,"time_end":30}]}]}`)) + })) + defer todbServer.Close() + + svc := NewMediaSegmentService(zap.NewNop(), repos). + SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(introServer.URL)). + SetTODb(NewTODbService(zap.NewNop()).SetBaseURL(todbServer.URL)) + if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, TODbSource); err != nil { + t.Fatal(err) + } + m := &model.Media{Base: model.Base{ID: "mv-todb"}, Path: "/movies/todb.mkv", TMDbID: 27205} + if err := repos.DB.Create(m).Error; err != nil { + t.Fatal(err) + } + + rows, err := svc.ListForPlayback(t.Context(), m) + if err != nil { + t.Fatalf("list: %v", err) + } + if atomic.LoadInt32(&introCalls) != 0 { + t.Fatalf("theintrodb was called %d times, want 0 after switching source", introCalls) + } + if atomic.LoadInt32(&todbCalls) != 1 { + t.Fatalf("todb calls = %d, want 1", todbCalls) + } + if len(rows) != 1 || rows[0].Source != TODbSource { + t.Fatalf("rows = %#v, want one row tagged %s", rows, TODbSource) + } + if rows[0].StartMs != 5000 || rows[0].EndMs != 30000 { + t.Fatalf("row = %#v, want 5000-30000ms", rows[0]) + } +} + +// 未知的源标识(降级回滚残留)要退回默认源,而不是静默失效。 +func TestSegmentSourceFallsBackForUnknownValue(t *testing.T) { + repos := repository.New(newServiceTestDB(t)) + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + _, _ = w.Write([]byte(introDBMoviePayload)) + })) + defer server.Close() + + svc := NewMediaSegmentService(zap.NewNop(), repos). + SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL)) + if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, "no-such-source"); err != nil { + t.Fatal(err) + } + m := &model.Media{Base: model.Base{ID: "mv-unknown"}, Path: "/movies/u.mkv", TMDbID: 27205} + if err := repos.DB.Create(m).Error; err != nil { + t.Fatal(err) + } + if _, err := svc.ListForPlayback(t.Context(), m); err != nil { + t.Fatalf("list: %v", err) + } + if atomic.LoadInt32(&calls) != 1 { + t.Fatalf("calls = %d, want the default provider to still run", calls) + } +} + +// IntroDB 需要 IMDb id;解析不出来时必须跳过请求,且不能写负缓存 +// (否则元数据补齐之后也永远不会再查)。 +func TestSegmentIntroDBSkipsWhenIMDbUnavailable(t *testing.T) { + repos := repository.New(newServiceTestDB(t)) + var calls int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + atomic.AddInt32(&calls, 1) + _, _ = w.Write([]byte(`{}`)) + })) + defer server.Close() + + svc := NewMediaSegmentService(zap.NewNop(), repos). + SetIntroDBApp(NewIntroDBAppService(zap.NewNop()).SetBaseURL(server.URL)). + SetIMDbResolver(&stubIMDbResolver{id: ""}) + if err := repos.Setting.Set(t.Context(), SegmentSourceSettingKey, IntroDBAppSource); err != nil { + t.Fatal(err) + } + m := &model.Media{Base: model.Base{ID: "mv-noimdb"}, Path: "/movies/noimdb.mkv", TMDbID: 27205} + if err := repos.DB.Create(m).Error; err != nil { + t.Fatal(err) + } + + rows, err := svc.ListForPlayback(t.Context(), m) + if err != nil { + t.Fatalf("list: %v", err) + } + if len(rows) != 0 || atomic.LoadInt32(&calls) != 0 { + t.Fatalf("rows = %#v calls = %d, want no request without an imdb id", rows, calls) + } + ledger, err := repos.MediaSegment.GetFetch(t.Context(), "mv-noimdb", IntroDBAppSource) + if err != nil { + t.Fatal(err) + } + if ledger != nil { + t.Fatalf("ledger = %#v, want no negative cache entry", ledger) + } +} diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 9ca074f..af8c9f7 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -134,8 +134,14 @@ func (b *serviceContainerBuilder) initContentServices() { b.c.Media = NewMediaService(b.cfg, b.log, b.repos).SetRuntimeCache(b.c.Cache) b.c.Stream = NewStreamService(b.cfg, b.log, b.repos, b.c.Transcoder) b.c.Playback = NewPlaybackService(b.log, b.repos).SetEmbyRemote(b.c.EmbyRemote) - // 片头/片尾片段:播放时按需向 TheIntroDB 补齐并落库,供下次直接命中。 - b.c.Segments = NewMediaSegmentService(b.log, b.repos).SetIntroDB(NewIntroDBService(b.log)) + // 片头/片尾片段:播放时按需向社区库补齐并落库,供下次直接命中。 + // 三个源都注册,具体用哪个由设置项 segment.source 决定(默认 theintrodb)。 + b.c.Segments = NewMediaSegmentService(b.log, b.repos). + SetIntroDB(NewIntroDBService(b.log)). + SetIntroDBApp(NewIntroDBAppService(b.log)). + SetTODb(NewTODbService(b.log)). + // IntroDB 只认 IMDb id,需要经 TMDb 的 external_ids 解析。 + SetIMDbResolver(b.c.TMDb) b.c.Subtitle = NewSubtitleService(b.cfg, b.log, b.repos) b.c.Profile = NewProfileService(b.log, b.repos) b.c.Audit = NewAuditService(b.log, b.repos) diff --git a/internal/service/tmdb.go b/internal/service/tmdb.go index 1ad6d4b..01afbc2 100644 --- a/internal/service/tmdb.go +++ b/internal/service/tmdb.go @@ -20,6 +20,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" + "strings" "time" "go.uber.org/zap" @@ -133,3 +135,34 @@ func (t *TMDbProvider) getJSON(ctx context.Context, url string, out any) error { } return json.NewDecoder(resp.Body).Decode(out) } + +// ResolveIMDbID 用 TMDb 的 external_ids 端点把 TMDb 条目解析成 IMDb id。 +// +// 这是为只认 IMDb id 的片段源(IntroDB)准备的。结果不做本地缓存:TMDb 的 +// external_ids 响应很小,而片段查询本身已经有 30 天/24 小时的正负缓存兜底, +// 再叠一层只会引入失效问题。 +// +// TMDb 未配置或上游不可达时返回空字符串与 error,调用方据此跳过该源, +// 而不是把所有媒体都记成「查不到」。 +func (t *TMDbProvider) ResolveIMDbID(ctx context.Context, tmdbID int, isMovie bool) (string, error) { + if t == nil || t.cfg == nil || tmdbID <= 0 { + return "", nil + } + apiKey := t.resolveAPIKey(ctx) + if apiKey == "" { + return "", nil + } + mediaType := "tv" + if isMovie { + mediaType = "movie" + } + u := t.resolveBaseURL(ctx) + "/" + mediaType + "/" + fmt.Sprint(tmdbID) + + "/external_ids?api_key=" + url.QueryEscape(apiKey) + var r struct { + IMDbID string `json:"imdb_id"` + } + if err := t.getJSON(ctx, u, &r); err != nil { + return "", err + } + return strings.TrimSpace(r.IMDbID), nil +} diff --git a/internal/service/todb.go b/internal/service/todb.go new file mode 100644 index 0000000..9764e84 --- /dev/null +++ b/internal/service/todb.go @@ -0,0 +1,213 @@ +// Package service — TODb (TheOtherDB) segment client. +// +// TODb 的播放资料库按「片源」组织:同一个 TMDB 条目可能有爱奇艺、腾讯等多个 +// 版本的 marker,每个版本自带 version_runtime(该片源的时长)。因此它的响应是 +// 一个 items 数组,而不是单一区间集合。 +// +// 接口是 POST + JSON body(GET 会返回 405),平台用 platform_type 表达: +// +// tmdb_id_tv + season_number / episode_number +// tmdb_id_movie 无季集 +// +// marker 的时间轴是**秒**(time_start / time_end),不是毫秒。 +package service + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "go.uber.org/zap" +) + +// TODbBaseURL 是公开只读端点。 +const TODbBaseURL = "https://playback.theotherdb.org/api/metadata" + +// TODbSource 写入 media_segments.source。 +const TODbSource = "todb" + +const ( + todbTimeout = 10 * time.Second + todbMaxBodySize = 1 << 20 + // todbPageSize 一次取多少条版本记录。社区库大多数条目只有一个片源, + // 但同一部剧的各平台版本可能都有人标,取多一点可以避免漏掉匹配时长的那个。 + todbPageSize = 10 +) + +// todbRequest 是 POST /api/metadata 的请求体。 +type todbRequest struct { + PlatformType string `json:"platform_type"` + PlatformValue string `json:"platform_value"` + SeasonNumber int `json:"season_number,omitempty"` + EpisodeNumber int `json:"episode_number,omitempty"` + Includes []string `json:"includes"` + Page int `json:"page"` + PageSize int `json:"page_size"` +} + +// todbMarker 是单个标记点。time_start/time_end 单位是秒。 +type todbMarker struct { + MarkerType string `json:"marker_type"` + TimeStart *float64 `json:"time_start"` + TimeEnd *float64 `json:"time_end"` +} + +// todbItem 是一个片源版本及其 marker。 +type todbItem struct { + VersionID int `json:"version_id"` + VersionName string `json:"version_name"` + VersionRuntime *int `json:"version_runtime"` + VideoType string `json:"video_type"` + Marker []todbMarker `json:"marker"` +} + +type todbResponse struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + HasMore bool `json:"has_more"` + Items []todbItem `json:"items"` +} + +// TODbService 查询 TODb 的片段数据。 +type TODbService struct { + log *zap.Logger + client *http.Client + baseURL string + pageSize int +} + +// NewTODbService is the constructor. +func NewTODbService(log *zap.Logger) *TODbService { + return &TODbService{ + log: log, + client: NewExternalHTTPClient(todbTimeout), + baseURL: TODbBaseURL, + pageSize: todbPageSize, + } +} + +// SetBaseURL overrides the API endpoint (tests, mirrors). +func (s *TODbService) SetBaseURL(base string) *TODbService { + if s != nil && strings.TrimSpace(base) != "" { + s.baseURL = strings.TrimSpace(base) + } + return s +} + +// Source implements SegmentProvider. +func (s *TODbService) Source() string { return TODbSource } + +// FetchSegments implements SegmentProvider. +// +// 返回时会把多个版本的 marker 合并:同 kind 取第一个出现的区间。TODb 的片源 +// 时长各不相同,严格按 version_runtime 匹配当前媒体需要可靠的总时长,而片段 +// 查询经常跑在时长还不知道的时候(STRM 媒体),所以这里不做版本筛选——宁可 +// 给一个近似位置,也好过整段缺失。真正需要精度的场景由 manual 标记覆盖。 +func (s *TODbService) FetchSegments(ctx context.Context, q SegmentQuery) ([]SegmentSpan, error) { + if s == nil || s.client == nil || q.TMDbID <= 0 { + return nil, nil + } + payload := todbRequest{ + PlatformValue: fmt.Sprint(q.TMDbID), + Includes: []string{"marker"}, + Page: 1, + PageSize: s.pageSize, + } + if q.IsMovie { + payload.PlatformType = "tmdb_id_movie" + } else { + payload.PlatformType = "tmdb_id_tv" + payload.SeasonNumber = q.Season + payload.EpisodeNumber = q.Episode + } + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.baseURL, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := s.client.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode == http.StatusNotFound { + return nil, nil + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("todb: unexpected status %d", resp.StatusCode) + } + raw, err := io.ReadAll(io.LimitReader(resp.Body, todbMaxBodySize)) + if err != nil { + return nil, err + } + return parseTODbResponse(raw) +} + +// parseTODbResponse 把各片源的 marker 合并成 spans。 +// +// marker_type 的映射沿用报告里的规则:intro -> intro,credits/post_credits -> +// credits(片尾),其余(chapter 等章节标记)忽略——章节标题没有可靠的语义, +// 当成片头会让跳过点漂到任意位置。 +func parseTODbResponse(body []byte) ([]SegmentSpan, error) { + var raw todbResponse + if err := json.Unmarshal(body, &raw); err != nil { + return nil, fmt.Errorf("parse todb json: %w", err) + } + seen := make(map[string]bool, 4) + spans := make([]SegmentSpan, 0, 4) + for _, item := range raw.Items { + for _, marker := range item.Marker { + kind := todbMarkerKind(marker.MarkerType) + if kind == "" || seen[kind] { + continue + } + if marker.TimeStart == nil || marker.TimeEnd == nil { + continue + } + startMs := int64(*marker.TimeStart * 1000) + endMs := int64(*marker.TimeEnd * 1000) + if startMs < 0 { + startMs = 0 + } + // 结束点缺失或非正表示「到片尾」,只有这样才是合法的开放区间; + // 与 TheIntroDB 不同,TODb 用 0 而不是 null 表示没标结束点, + // 所以这里把 <= start 的片尾当作开放区间。 + if endMs <= startMs { + if kind != "credits" && kind != "preview" { + continue + } + endMs = 0 + } + spans = append(spans, SegmentSpan{Kind: kind, StartMs: startMs, EndMs: endMs}) + seen[kind] = true + } + } + return spans, nil +} + +// todbMarkerKind 把 TODb 的 marker_type 映射成库内 kind。 +func todbMarkerKind(markerType string) string { + switch strings.ToLower(strings.TrimSpace(markerType)) { + case "intro": + return "intro" + case "recap": + return "recap" + case "credits", "post_credits", "outro": + return "credits" + case "preview": + return "preview" + default: + return "" + } +} diff --git a/internal/service/todb_test.go b/internal/service/todb_test.go new file mode 100644 index 0000000..bdf93d2 --- /dev/null +++ b/internal/service/todb_test.go @@ -0,0 +1,112 @@ +package service + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "go.uber.org/zap" +) + +// TODb 的接口是 POST + JSON body(GET 返回 405),平台类型用 tmdb_id_tv / +// tmdb_id_movie 表达,marker 的时间轴是秒。 +func TestTODbFetchSendsPostWithTMDBPlatform(t *testing.T) { + var body map[string]any + var method string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + method = r.Method + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{"page":1,"page_size":10,"has_more":false,"items":[]}`)) + })) + defer server.Close() + + svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL) + if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 226045, Season: 1, Episode: 1}); err != nil { + t.Fatalf("fetch: %v", err) + } + if method != http.MethodPost { + t.Fatalf("method = %s, want POST", method) + } + if body["platform_type"] != "tmdb_id_tv" || body["platform_value"] != "226045" { + t.Fatalf("body = %#v, want tmdb_id_tv/226045", body) + } + if body["season_number"] != float64(1) || body["episode_number"] != float64(1) { + t.Fatalf("body = %#v, want season/episode 1", body) + } +} + +func TestTODbFetchUsesMoviePlatform(t *testing.T) { + var body map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(raw, &body) + _, _ = w.Write([]byte(`{"items":[]}`)) + })) + defer server.Close() + + svc := NewTODbService(zap.NewNop()).SetBaseURL(server.URL) + if _, err := svc.FetchSegments(t.Context(), SegmentQuery{TMDbID: 83533, IsMovie: true}); err != nil { + t.Fatalf("fetch: %v", err) + } + if body["platform_type"] != "tmdb_id_movie" { + t.Fatalf("platform_type = %v, want tmdb_id_movie", body["platform_type"]) + } + if _, ok := body["season_number"]; ok { + t.Fatalf("body = %#v, want no season_number for a movie", body) + } +} + +// 实测返回:marker 的时间是秒,marker_type 为 intro。 +func TestParseTODbResponseConvertsSecondsToMilliseconds(t *testing.T) { + body := `{"page":1,"page_size":3,"has_more":false,"items":[ + {"version_id":1192,"version_name":"爱奇艺","version_runtime":1300,"video_type":"tv", + "marker":[{"marker_id":1588,"marker_type":"intro","time_start":0,"time_end":27}]}]}` + spans, err := parseTODbResponse([]byte(body)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(spans) != 1 { + t.Fatalf("spans = %#v, want 1", spans) + } + if spans[0].Kind != "intro" || spans[0].StartMs != 0 || spans[0].EndMs != 27_000 { + t.Fatalf("span = %#v, want intro 0-27000ms", spans[0]) + } +} + +func TestParseTODbResponseMapsMarkerTypes(t *testing.T) { + body := `{"items":[{"marker":[ + {"marker_type":"intro","time_start":10,"time_end":30}, + {"marker_type":"credits","time_start":100,"time_end":120}, + {"marker_type":"post_credits","time_start":130,"time_end":140}, + {"marker_type":"chapter","time_start":5,"time_end":6} + ]}]}` + spans, err := parseTODbResponse([]byte(body)) + if err != nil { + t.Fatalf("parse: %v", err) + } + // chapter 被忽略;post_credits 与 credits 同为片尾,同 kind 只保留第一个。 + if len(spans) != 2 { + t.Fatalf("spans = %#v, want intro + credits", spans) + } + if spans[0].Kind != "intro" || spans[1].Kind != "credits" { + t.Fatalf("kinds = %s/%s, want intro/credits", spans[0].Kind, spans[1].Kind) + } +} + +// 片尾的结束点缺失或非正表示「到片尾」,是合法的开放区间;片头不行。 +func TestParseTODbResponseAllowsOpenEndedCreditsOnly(t *testing.T) { + body := `{"items":[{"marker":[ + {"marker_type":"intro","time_start":10,"time_end":10}, + {"marker_type":"credits","time_start":100,"time_end":100} + ]}]}` + spans, err := parseTODbResponse([]byte(body)) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(spans) != 1 || spans[0].Kind != "credits" || spans[0].EndMs != 0 { + t.Fatalf("spans = %#v, want only an open-ended credits", spans) + } +} diff --git a/introdb_app_tmp.json b/introdb_app_tmp.json new file mode 100644 index 0000000..23c56ea --- /dev/null +++ b/introdb_app_tmp.json @@ -0,0 +1 @@ +{"openapi":"3.1.0","info":{"title":"IntroDB API","description":"Public API for fetching and submitting segment timestamps (intro, recap, outro) for TV shows, plus end-credits and post-credits segments for movies.","version":"1.0.0","contact":{"name":"IntroDB","url":"https://introdb.app"}},"servers":[{"url":"https://api.introdb.app","description":"Current environment"}],"tags":[{"name":"intros","description":"Public intros endpoints"},{"name":"segments","description":"Public segment endpoints (TV: intro, recap, outro · Movies: end credits, post-credits)"},{"name":"submissions","description":"Submit segment timestamps programmatically"}],"components":{"securitySchemes":{"ApiKeyAuth":{"type":"apiKey","in":"header","name":"X-API-Key","description":"API key for programmatic submissions (format: idb_...)"}}},"paths":{"/intro":{"get":{"tags":["intros"],"summary":"Get intro timestamps for an episode","description":"Returns the aggregated intro start/end times for a specific TV show episode. Crowdsourced data from community submissions.","operationId":"getIntro","parameters":[{"name":"imdb_id","in":"query","required":true,"description":"IMDb ID (e.g., tt0944947 for Game of Thrones)","schema":{"type":"string","pattern":"^tt[0-9]{7,8}","examples":{"got":{"value":"tt0944947","summary":"Game of Thrones"},"breakingBad":{"value":"tt0903747","summary":"Breaking Bad"}}}},{"name":"season","in":"query","required":true,"description":"Season number (starting from 1)","schema":{"type":"integer","minimum":1,"examples":{"season1":{"value":1,"summary":"Season 1"},"season3":{"value":3,"summary":"Season 3"}}}},{"name":"episode","in":"query","required":true,"description":"Episode number (starting from 1)","schema":{"type":"integer","minimum":1,"examples":{"episode1":{"value":1,"summary":"Episode 1"},"episode10":{"value":10,"summary":"Episode 10"}}}}],"responses":{"200":{"description":"Successfully retrieved intro timestamps","content":{"application/json":{"schema":{"type":"object","properties":{"imdb_id":{"type":"string","description":"IMDb ID"},"season":{"type":"integer","description":"Season number"},"episode":{"type":"integer","description":"Episode number"},"start_ms":{"type":"integer","description":"Intro start time in milliseconds"},"end_ms":{"type":"integer","description":"Intro end time in milliseconds"},"confidence":{"type":"number","format":"float","description":"Confidence score (0-1) based on submission agreement"},"submission_count":{"type":"integer","description":"Number of submissions that contributed to this aggregate"}},"required":["imdb_id","season","episode","start_ms","end_ms","confidence","submission_count"],"examples":{"gotS1E1":{"summary":"Game of Thrones S1E1","value":{"imdb_id":"tt0944947","season":1,"episode":1,"start_ms":1850000,"end_ms":2025000,"confidence":0.95,"submission_count":12}}}}}}},"400":{"description":"Invalid request parameters","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Invalid IMDb ID format"}}}}}},"404":{"description":"Intro not found for this episode","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Intro not found"}}}}}}}}},"/segments":{"get":{"tags":["segments"],"summary":"Get intro/recap/outro/post-credits segments for an episode or movie","description":"Returns aggregated segment timestamps for a specific TV episode, or for a movie when is_movie=true. Missing segments are returned as null.","operationId":"getSegments","parameters":[{"name":"imdb_id","in":"query","required":true,"schema":{"type":"string","pattern":"^tt[0-9]{7,8}"}},{"name":"season","in":"query","required":false,"schema":{"type":"integer","minimum":1},"description":"Season number (TV only)"},{"name":"episode","in":"query","required":false,"schema":{"type":"integer","minimum":1},"description":"Episode number (TV only)"},{"name":"is_movie","in":"query","required":false,"schema":{"type":"boolean"},"description":"Set to true to fetch movie segments (ignores season/episode)"}],"responses":{"200":{"description":"Successfully retrieved segments","content":{"application/json":{"schema":{"type":"object","properties":{"imdb_id":{"type":"string"},"media_type":{"type":"string","enum":["tv","movie"]},"is_movie":{"type":"boolean"},"season":{"type":"integer"},"episode":{"type":"integer"},"intro":{"oneOf":[{"type":"null"},{"type":"object","properties":{"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"start_sec":{"type":"number"},"end_sec":{"type":"number"},"confidence":{"type":"number"},"submission_count":{"type":"integer"}},"required":["start_ms","end_ms","start_sec","end_sec","confidence","submission_count"]}]},"recap":{"oneOf":[{"type":"null"},{"type":"object","properties":{"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"start_sec":{"type":"number"},"end_sec":{"type":"number"},"confidence":{"type":"number"},"submission_count":{"type":"integer"}},"required":["start_ms","end_ms","start_sec","end_sec","confidence","submission_count"]}]},"outro":{"oneOf":[{"type":"null"},{"type":"object","properties":{"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"start_sec":{"type":"number"},"end_sec":{"type":"number"},"confidence":{"type":"number"},"submission_count":{"type":"integer"}},"required":["start_ms","end_ms","start_sec","end_sec","confidence","submission_count"]}]},"post_credits":{"oneOf":[{"type":"null"},{"type":"object","properties":{"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"start_sec":{"type":"number"},"end_sec":{"type":"number"},"confidence":{"type":"number"},"submission_count":{"type":"integer"}},"required":["start_ms","end_ms","start_sec","end_sec","confidence","submission_count"]}]}},"required":["imdb_id","season","episode","intro","recap","outro"]}}}},"400":{"description":"Invalid request parameters"},"404":{"description":"No segments found for this episode"}}}},"/outro":{"get":{"tags":["segments"],"summary":"Get the end-credits (outro) segment for a movie","description":"Convenience endpoint that returns the aggregated end-credits timestamps for a movie.","operationId":"getMovieOutro","parameters":[{"name":"imdb_id","in":"query","required":true,"schema":{"type":"string","pattern":"^tt[0-9]{7,8}"}}],"responses":{"200":{"description":"Successfully retrieved the movie outro","content":{"application/json":{"schema":{"type":"object","properties":{"imdb_id":{"type":"string"},"media_type":{"type":"string","enum":["movie"]},"segment_type":{"type":"string","enum":["outro"]},"start_sec":{"type":"number"},"end_sec":{"type":"number"},"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"confidence":{"type":"number"},"submission_count":{"type":"integer"},"updated_at":{"type":"string"}},"required":["imdb_id","start_ms","end_ms","confidence","submission_count"]}}}},"400":{"description":"Invalid request parameters"},"404":{"description":"No movie outro found"}}}},"/submit":{"post":{"tags":["submissions"],"summary":"Submit segment timestamps (API key required)","description":"Submit intro/recap/outro/post-credits timestamps for a TV episode or a movie (is_movie=true) using your API key.","operationId":"submitSegment","security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["segment_type","imdb_id","start_sec","end_sec"],"properties":{"segment_type":{"type":"string","enum":["intro","recap","outro","post-credits"],"description":"Segment type (outro/post-credits are the movie segment types)"},"imdb_id":{"type":"string","pattern":"^tt[0-9]{7,8}","description":"IMDb ID","example":"tt0903747"},"is_movie":{"type":"boolean","description":"Set true when submitting movie timestamps; season/episode are then omitted","default":false},"season":{"type":"integer","minimum":1,"description":"Season number","example":1},"episode":{"type":"integer","minimum":1,"description":"Episode number","example":1},"start_sec":{"type":"number","minimum":0,"description":"Segment start time in seconds","example":2.5},"end_sec":{"type":"number","minimum":0,"description":"Segment end time in seconds","example":58},"tvdb_id":{"type":"integer","description":"TVDB series ID (optional, improves accuracy)","example":81189},"tmdb_id":{"type":"integer","description":"TMDB series ID (optional, improves accuracy)","example":1396}}},"examples":{"breakingBad":{"summary":"Breaking Bad intro","value":{"segment_type":"intro","imdb_id":"tt0903747","season":1,"episode":1,"start_sec":2.5,"end_sec":58,"tvdb_id":81189,"tmdb_id":1396}},"fightClub":{"summary":"Fight Club post-credits (movie)","value":{"segment_type":"post-credits","imdb_id":"tt0137523","is_movie":true,"start_sec":7650,"end_sec":7800,"tmdb_id":550}}}}}},"responses":{"200":{"description":"Submission created successfully","content":{"application/json":{"schema":{"type":"object","properties":{"ok":{"type":"boolean","example":true},"submission":{"type":"object","properties":{"id":{"type":"string","format":"uuid","description":"Submission ID"},"imdbId":{"type":"string","description":"IMDb ID"},"season":{"type":"integer"},"episode":{"type":"integer"},"startMs":{"type":"integer","description":"Start time in milliseconds"},"endMs":{"type":"integer","description":"End time in milliseconds"},"status":{"type":"string","enum":["pending","accepted","rejected"],"description":"Submission status"},"weight":{"type":"integer","description":"Submission weight"}}}}}}}},"400":{"description":"Invalid request (bad format, invalid episode, duration out of range)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","examples":["Invalid payload.","Episode does not exist for this show.","recap duration must be between 5s and 480s."]}}}}}},"401":{"description":"Invalid or missing API key","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Invalid API key"}}}}}},"429":{"description":"Rate limited (1 submission per segment and episode per 5 minutes)","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Rate limited for this segment and episode."}}}}}}}}}}} \ No newline at end of file diff --git a/web/src/api/playback.ts b/web/src/api/playback.ts index 2138b8e..bdd9bb5 100644 --- a/web/src/api/playback.ts +++ b/web/src/api/playback.ts @@ -54,6 +54,16 @@ export const playbackAPI = { .get(`/playback/${encodeURIComponent(mediaId)}/segments`) .then((r) => r.data), + // 手动标记:把当前位置记为片头结束 / 片尾开始(clear 则清除)。服务端会按季 + // 传播到同季其它尚未标记的集,并返回合并后的完整片段列表。 + markSegment: ( + mediaId: string, + payload: { kind: 'intro' | 'credits'; position_ms?: number; clear?: boolean }, + ) => + api + .put(`/playback/${encodeURIComponent(mediaId)}/segments`, payload) + .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 0dae777..76d939c 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -88,6 +88,16 @@ type PlayerDiagnosticEvent = { at: string; phase: string; mode: string; message: // 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。 const SKIP_NOTICE_MS = 6000 +// 手动标记的提示里展示的时间点(mm:ss 或 h:mm:ss)。 +function formatMarkTime(totalSec: number): string { + const safe = Math.max(0, Math.floor(totalSec)) + const hours = Math.floor(safe / 3600) + const minutes = Math.floor((safe % 3600) / 60) + const seconds = safe % 60 + const pad = (n: number) => String(n).padStart(2, '0') + return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}` +} + // 115 云 HLS 中途断流后,允许在窗口内重建几次会话再退回本地转码。 const CLOUD_HLS_RELOAD_MAX = 3 const CLOUD_HLS_RELOAD_WINDOW_MS = 10 * 60 * 1000 @@ -2245,6 +2255,73 @@ export function PlayerPage() { if (activeSkip) performSkip(activeSkip) }, [activeSkip, performSkip]) + // ── 手动标记片头/片尾 ────────────────────────────────────────────────────── + // 服务端把手动标记的优先级排在最前,并按季传播到还没标记的集,所以这里只需 + // 把「当前播放位置」发过去,再用返回的合并结果替换本地片段。 + const manualMarkBusyRef = useRef(false) + const submitManualMark = useCallback( + (kind: 'intro' | 'credits', clear: boolean) => { + const current = mediaRef.current + if (!current?.id || manualMarkBusyRef.current) return + // 位置必须是源时间轴上的绝对秒数:本地 HLS 的 t=0 对应 hlsStartSec。 + const video = ref.current + const positionSec = playbackPositionSec( + modeRef.current, + hlsSourceRef.current, + hlsStartSecRef.current, + video?.currentTime || 0, + ) + const positionMs = Math.max(0, Math.round(positionSec * 1000)) + if (!clear && positionMs <= 0) { + toast.error('当前位置为 0,请播放到片头结束后再标记') + return + } + manualMarkBusyRef.current = true + const markLabel = kind === 'intro' ? '片头' : '片尾' + const markTime = formatMarkTime(positionSec) + playbackAPI + .markSegment(current.id, { + kind, + position_ms: clear ? undefined : positionMs, + clear, + }) + .then((res) => { + setRawSkipSegments(res.segments ?? []) + setAutoSkipIntro(Boolean(res.auto_skip)) + setActiveSkip(null) + activeSkipKeyRef.current = '' + setDismissedSkipKinds([]) + setAutoSuppressedKinds([]) + if (clear) { + toast.success(`已清除${markLabel}标记`) + } else { + toast.success(`已标记${markLabel}:${markTime}(同季其它集已同步)`) + } + }) + .catch(() => toast.error('标记保存失败,请稍后重试')) + .finally(() => { + manualMarkBusyRef.current = false + }) + }, + [], + ) + + const handleMarkIntro = useCallback(() => submitManualMark('intro', false), [submitManualMark]) + const handleMarkOutro = useCallback(() => submitManualMark('credits', false), [submitManualMark]) + const handleClearIntroMark = useCallback(() => submitManualMark('intro', true), [submitManualMark]) + const handleClearOutroMark = useCallback(() => submitManualMark('credits', true), [submitManualMark]) + + // 已有手动标记的类型:用于按钮提示文案。只有手动标记可以清除,外部源的不算。 + const markPresence = useMemo(() => { + let intro = false + let outro = false + for (const segment of rawSkipSegments) { + if (segment.kind === 'intro') intro = true + if (segment.kind === 'credits') outro = true + } + return { intro, outro } + }, [rawSkipSegments]) + const handleUndoSkip = useCallback(() => { const notice = skipNotice setSkipNotice(null) @@ -2547,6 +2624,12 @@ export function PlayerPage() { onSkipPrompt={handleSkipClick} skipNotice={skipNotice ? { text: skipNotice.text } : null} onUndoSkip={handleUndoSkip} + onMarkIntro={handleMarkIntro} + onMarkOutro={handleMarkOutro} + onClearIntroMark={handleClearIntroMark} + onClearOutroMark={handleClearOutroMark} + hasIntroMark={markPresence.intro} + hasOutroMark={markPresence.outro} playlistPanel={ isMobileTheater ? undefined diff --git a/web/src/pages/PlayerVideoStage.tsx b/web/src/pages/PlayerVideoStage.tsx index e4d3ab0..83f103c 100644 --- a/web/src/pages/PlayerVideoStage.tsx +++ b/web/src/pages/PlayerVideoStage.tsx @@ -242,6 +242,15 @@ type PlayerVideoStageProps = { /** 自动跳过后的撤销提示;null 表示不显示。 */ skipNotice?: { text: string } | null onUndoSkip?: () => void + /** 手动标记:把当前位置记为片头结束 / 片尾开始。 */ + onMarkIntro?: () => void + onMarkOutro?: () => void + /** 长按清除已有的片头 / 片尾标记。 */ + onClearIntroMark?: () => void + onClearOutroMark?: () => void + /** 当前是否已有对应类型的标记(决定按钮文案与长按是否可用)。 */ + hasIntroMark?: boolean + hasOutroMark?: boolean waiting?: boolean waitingMessage?: string } @@ -312,6 +321,12 @@ export function PlayerVideoStage({ onSkipPrompt, skipNotice = null, onUndoSkip, + onMarkIntro, + onMarkOutro, + onClearIntroMark, + onClearOutroMark, + hasIntroMark = false, + hasOutroMark = false, waiting = false, waitingMessage = '', }: PlayerVideoStageProps) { @@ -966,6 +981,48 @@ export function PlayerVideoStage({ ) : null} {danmakuPanel} {playlistPanel} + {/* 手动标记片头/片尾:数据源查不到时用户自己标一次,服务端会按季传播。 + 放在左下角,与右侧的「跳过」按钮分开,避免误触。 */} + {!locked && (onMarkIntro || onMarkOutro) ? ( +
event.stopPropagation()} + > + 标记 + + +
+ ) : null} ) : loadError ? (

{loadError}

diff --git a/web/src/pages/settingsGroupGeneral.ts b/web/src/pages/settingsGroupGeneral.ts index 45e2c07..738ea93 100644 --- a/web/src/pages/settingsGroupGeneral.ts +++ b/web/src/pages/settingsGroupGeneral.ts @@ -23,11 +23,24 @@ export const generalSettingsGroup: SettingGroup = { hint: '默认关闭。开启后宿主机不再进行任何 FFmpeg 转码,所有播放交给第三方客户端(Infuse / VLC / Emby 客户端等)或浏览器本地解码直连(direct play / 302 直链),大幅降低宿主机 CPU 占用。若客户端不支持源编码可能无法播放。', defaultValue: 'false', }, + { + key: 'segment.source', + label: '片头/片尾数据源', + type: 'select', + hint: '选择查询哪个社区库来补全片头/片尾时间点。TheIntroDB 数据最全(按 TMDb id);IntroDB 只认 IMDb id,需要 TMDb 已配置 API key 才能解析;TODb 偏华语片源、按平台版本记录。选「关闭」后只使用本地已有数据(含手动标记),不再请求外网。', + defaultValue: 'theintrodb', + options: [ + { value: 'theintrodb', label: 'TheIntroDB' }, + { value: 'introdb', label: 'IntroDB' }, + { value: 'todb', label: 'TODb(TheOtherDB)' }, + { value: 'off', label: '关闭' }, + ], + }, { key: 'segment.prewarm_enabled', - label: '后台预热 IntroDB 片头片段', + label: '后台预热片头片段', type: 'toggle', - hint: '默认关闭。开启后每 6 小时在后台向 TheIntroDB 拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。', + hint: '默认关闭。开启后每 6 小时在后台向上面选择的数据源拉取可识别媒体的片头/片尾区间并写入本地;关闭时仅在播放时按需请求。', defaultValue: 'false', }, { diff --git a/web/src/utils/skipSegments.test.ts b/web/src/utils/skipSegments.test.ts index 2cc28d8..b89478f 100644 --- a/web/src/utils/skipSegments.test.ts +++ b/web/src/utils/skipSegments.test.ts @@ -15,7 +15,7 @@ function equal(name: string, actual: T, expected: T) { check('空片段返回空数组', toSkipSegments(null, 600).length === 0) check('空数组返回空数组', toSkipSegments([], 600).length === 0) -// end_ms: 0 表示延续到片尾,用媒体时长补齐。 +// end_ms: 0 只在片尾类(credits/preview)表示「延续到片尾」,用媒体时长补齐。 const resolved = toSkipSegments( [ { kind: 'intro', start_ms: 228_664, end_ms: 246_143 }, @@ -28,6 +28,17 @@ equal('片头起点(秒)', resolved[0].startSec, 228.664) equal('片头终点(秒)', resolved[0].endSec, 246.143) equal('片尾终点补齐为总时长', resolved[1].endSec, 3600) +// 片头 / 回顾的 end_ms: 0 是数据源的「这段不存在」占位(TheIntroDB 官方语义), +// 绝不能补齐成总时长——那会让整集都落在片头区间里,一开播就弹跳过按钮。 +check( + '片头 end_ms=0 不得补齐为整集', + toSkipSegments([{ kind: 'intro', start_ms: 0, end_ms: 0 }], 3600).length === 0, +) +check( + '回顾 end_ms=0 不得补齐为整集', + toSkipSegments([{ kind: 'recap', start_ms: 0, end_ms: 0 }], 3600).length === 0, +) + // 时长未知时,开放区间必须丢弃:否则会算出一个永远命中的区间。 check( '时长未知时丢弃开放区间', diff --git a/web/src/utils/skipSegments.ts b/web/src/utils/skipSegments.ts index a97da2a..63bda59 100644 --- a/web/src/utils/skipSegments.ts +++ b/web/src/utils/skipSegments.ts @@ -33,8 +33,11 @@ export function skippedNoticeText(kind: PlaybackSegmentKind): string { /** * 把服务端返回的片段换算成源时间轴上的秒区间。 * - * end_ms === 0 表示区间延续到片尾,用媒体总时长补齐;时长未知(0 / 非有限值) - * 时丢弃该区间——否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。 + * end_ms === 0 是「一直到片尾」的哨兵值,只有片尾类(credits / preview)会这么 + * 返回,用媒体总时长补齐。片头 / 回顾出现 0 一律丢弃:那通常意味着数据源根本没 + * 给出结束点(TheIntroDB 用 end_ms: 0 表示「这段不存在」),补成总时长会让整集 + * 都落在「片头」区间里,一开播就弹跳过按钮。时长未知(0 / 非有限值)同样丢弃, + * 否则会算出一个永远命中的区间,浮层上的按钮就再也点不掉了。 */ export function toSkipSegments( segments: PlaybackSegment[] | null | undefined, @@ -44,9 +47,16 @@ export function toSkipSegments( 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 }) + const openEnded = segment.kind === 'credits' || segment.kind === 'preview' + if (segment.end_ms > 0) { + const endSec = segment.end_ms / 1000 + if (!Number.isFinite(endSec) || endSec <= startSec) continue + out.push({ kind: segment.kind, startSec, endSec }) + continue + } + if (!openEnded) continue + if (!Number.isFinite(durationSec) || durationSec <= startSec) continue + out.push({ kind: segment.kind, startSec, endSec: durationSec }) } return out.sort((a, b) => a.startSec - b.startSec) }