From 28485ed429b2dfc4490737c95baf3d44b4e8511c Mon Sep 17 00:00:00 2001 From: truewhile <779943132@qq.com> Date: Wed, 23 Sep 2026 17:01:08 +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 --- internal/handler/playback_segments.go | 26 +- internal/model/playback_collection.go | 31 ++- internal/service/media_probe.go | 58 ++--- internal/service/media_probe_chapters.go | 72 ------ internal/service/media_probe_chapters_test.go | 111 --------- internal/service/media_probe_test.go | 27 +-- internal/service/media_segment.go | 138 +++-------- internal/service/media_segment_probe_test.go | 77 ++++++ internal/service/media_segment_source_test.go | 223 ------------------ internal/service/play_profile.go | 2 - internal/service/play_profile_model.go | 26 +- web/src/api/play_profiles.ts | 3 +- web/src/pages/PlayerPage.tsx | 36 +-- web/src/pages/ProfileFormModal.tsx | 1 - web/src/pages/ProfileFormSections.tsx | 11 - web/src/types/playProfiles.ts | 4 - web/src/types/playback.ts | 11 - 17 files changed, 170 insertions(+), 687 deletions(-) delete mode 100644 internal/service/media_probe_chapters.go delete mode 100644 internal/service/media_probe_chapters_test.go create mode 100644 internal/service/media_segment_probe_test.go delete mode 100644 internal/service/media_segment_source_test.go diff --git a/internal/handler/playback_segments.go b/internal/handler/playback_segments.go index ef06393..35d5abe 100644 --- a/internal/handler/playback_segments.go +++ b/internal/handler/playback_segments.go @@ -22,9 +22,7 @@ import ( func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { autoSkip := resolveAutoSkipFlag(c, svc) - source := resolveSegmentSource(c, svc) segments := []service.SegmentView{} - pending := false m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id")) if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) { @@ -33,22 +31,14 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc { } // 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。 if svc.Segments != nil && !service.IsEmbyRemoteID(m.ID) { - result, listErr := svc.Segments.SegmentsForPlayback(c.Request.Context(), m, source) + rows, listErr := svc.Segments.ListForPlayback(c.Request.Context(), m) if listErr != nil && svc.Log != nil { svc.Log.Debug("list media segments failed", zap.String("media_id", m.ID), zap.Error(listErr)) } - segments = service.ToSegmentViews(result.Segments) - pending = result.Pending + segments = service.ToSegmentViews(rows) } - // pending 告诉客户端「章节提取还在后台跑,过几秒再拉一次」;提取完成时 - // 如果片头还没播完,跳过按钮就会自己出现,已经过了片头则不会提示。 - c.JSON(http.StatusOK, gin.H{ - "segments": segments, - "auto_skip": autoSkip, - "pending": pending, - "source": source, - }) + c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip}) } } @@ -63,13 +53,3 @@ func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool { } return profile.SkipIntro } - -// resolveSegmentSource reads the「片头片尾数据来源」choice off the active profile. -// PIN-locked profiles fall back to auto rather than leaking the profile's setting. -func resolveSegmentSource(c *gin.Context, svc *service.Container) string { - profile, locked := selectedPlayProfile(c, svc) - if locked || profile == nil { - return service.SegmentSourceAuto - } - return service.NormalizeSegmentSource(profile.SegmentSource) -} diff --git a/internal/model/playback_collection.go b/internal/model/playback_collection.go index 5c7bbf5..5e27722 100644 --- a/internal/model/playback_collection.go +++ b/internal/model/playback_collection.go @@ -53,21 +53,18 @@ type PlaylistItem struct { // AllowedLibraryIDs is a JSON array of library UUIDs (empty = all). type PlayProfile struct { Base - UserID string `gorm:"index;size:36;not null" json:"user_id"` - Name string `gorm:"size:64;not null" json:"name"` - IsDefault bool `gorm:"default:false" json:"is_default"` - ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"` - AllowAdult bool `gorm:"default:false" json:"allow_adult"` - RequirePIN bool `gorm:"default:false" json:"require_pin"` - PINHash string `gorm:"size:128" json:"-"` - PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"` - PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"` - AutoplayNext bool `gorm:"default:true" json:"autoplay_next"` - SkipIntro bool `gorm:"default:false" json:"skip_intro"` - // SegmentSource 决定片头/片尾数据从哪来:auto(优先文件内嵌章节,没有可用 - // 章节时回落到 TheIntroDB)/ theintrodb / ffprobe。空值按 auto 处理。 - SegmentSource string `gorm:"size:16;default:'auto'" json:"segment_source,omitempty"` - AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"` - TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"` - LastActiveAt *time.Time `json:"last_active_at,omitempty"` + UserID string `gorm:"index;size:36;not null" json:"user_id"` + Name string `gorm:"size:64;not null" json:"name"` + IsDefault bool `gorm:"default:false" json:"is_default"` + ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"` + AllowAdult bool `gorm:"default:false" json:"allow_adult"` + RequirePIN bool `gorm:"default:false" json:"require_pin"` + PINHash string `gorm:"size:128" json:"-"` + PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"` + PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"` + AutoplayNext bool `gorm:"default:true" json:"autoplay_next"` + SkipIntro bool `gorm:"default:false" json:"skip_intro"` + AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"` + TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"` + LastActiveAt *time.Time `json:"last_active_at,omitempty"` } diff --git a/internal/service/media_probe.go b/internal/service/media_probe.go index b1fba0b..bd274d0 100644 --- a/internal/service/media_probe.go +++ b/internal/service/media_probe.go @@ -18,18 +18,7 @@ import ( "github.com/truewhile/MeBox/internal/repository" ) -// 播放档案里可选的片头/片尾数据来源。 -const ( - // SegmentSourceAuto 优先用文件内嵌章节(对这个片源最准),没有可用章节时 - // 回落到 TheIntroDB。 - SegmentSourceAuto = "auto" - // SegmentSourceFFprobe 只用文件内嵌章节。 - SegmentSourceFFprobe = "ffprobe" - // SegmentSourceTheIntroDB 只用社区数据库。它同时是 media_segments.source 的 - // 取值——两处必须一致,所以直接复用提供方的常量。 - SegmentSourceTheIntroDB = IntroDBSource -) - +// 探测结果的缓存与预算策略。 const ( // mediaProbeTimeout 是一次后台探测的总预算(含把 strm 目标解析成直链)。 mediaProbeTimeout = 90 * time.Second @@ -41,31 +30,25 @@ const ( mediaProbeErrorLimit = 200 ) -// NormalizeSegmentSource 把任意输入收敛到合法取值;未知值一律按 auto 处理 -// (历史档案里这一列可能还是空串)。 -func NormalizeSegmentSource(raw string) string { - switch strings.ToLower(strings.TrimSpace(raw)) { - case SegmentSourceFFprobe: - return SegmentSourceFFprobe - case SegmentSourceTheIntroDB: - return SegmentSourceTheIntroDB - default: - return SegmentSourceAuto - } -} - // mediaProber 是 MediaProbeService 需要的探测能力。抽成接口是为了在测试里注入 // 桩,避免依赖真实 ffprobe 二进制。 type mediaProber interface { ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error) } -// MediaProbeService 用 ffprobe 提取媒体的完整信息(容器、每路轨道、内嵌章节), -// 把结果落库缓存,并把章节映射成可跳过的片头/片尾区间。 +// MediaProbeService 用 ffprobe 提取媒体的基础信息(容器、每路轨道、内嵌章节) +// 并落库缓存。 // -// 核心约束:一次探测要 3~4 秒(远端直链更慢,跨洋要跑三次 HTTP 事务),所以 -// 只允许异步跑。播放链路永远只读缓存,拿不到就下次再来——绝不能让一次探测挡在 -// 起播路径上。 +// 定位是「播放时顺带补齐媒体信息」:STRM / 云盘媒体在扫描阶段拿不到时长,而 +// 播放链路要用缓存里的时长来换算「延续到片尾」这类区间,详情页将来也直接读这份 +// 媒体信息。 +// +// 它**不参与片头/片尾判定**:章节标题绝大多数没有语义(生产库实测抽样 64 个 +// 文件,命中 0 个),拿它去猜跳过点只会给出错误的位置,时间轴数据仍然只信 +// TheIntroDB。 +// +// 核心约束:一次探测要 2~4.5 秒(远端直链要跨洋跑几次 HTTP 事务),所以只允许 +// 异步跑,播放链路永远只读缓存。 type MediaProbeService struct { log *zap.Logger repo *repository.Container @@ -200,22 +183,11 @@ func (s *MediaProbeService) probeInput(ctx context.Context, m *model.Media) (Pro return ProbeInput{}, errors.New("strm probe source unavailable") } -// persistProbe 把一次成功的探测落库:先写片段区间,再写探测行。 -// -// 顺序很重要:探测行是「已经探过」的标记,客户端靠它决定要不要继续轮询。先写 -// 它会让客户端在区间还没落库时就停止等待。 +// persistProbe 把一次成功的探测落库。 func (s *MediaProbeService) persistProbe(ctx context.Context, m *model.Media, result *FullProbeResult) error { - if s.repo == nil || s.repo.MediaProbe == nil || s.repo.MediaSegment == nil { + if s.repo == nil || s.repo.MediaProbe == nil { return errors.New("media probe repository not wired") } - rows := chaptersToSegments(result.Chapters) - for i := range rows { - rows[i].MediaID = m.ID - rows[i].SeriesID = m.SeriesID - } - if err := s.repo.MediaSegment.ReplaceForMedia(ctx, m.ID, SegmentSourceFFprobe, rows); err != nil { - return err - } payload, err := result.PayloadJSON() if err != nil { return err diff --git a/internal/service/media_probe_chapters.go b/internal/service/media_probe_chapters.go deleted file mode 100644 index b6fc7e5..0000000 --- a/internal/service/media_probe_chapters.go +++ /dev/null @@ -1,72 +0,0 @@ -package service - -import ( - "regexp" - "strings" - - "github.com/truewhile/MeBox/internal/model" -) - -// chapterMinMs 是章节被判为「可跳过区间」的最短长度。太短的章节没有可跳过的 -// 内容,多半是章节切分噪声。 -const chapterMinMs = 2000 - -// chapterKindPatterns 是章节标题 → 片段类型的映射表。 -// -// 只认「有语义的」标题。大量发布组的章节叫 Chapter 01/02…,那种一律不猜: -// auto 档会优先采用章节数据,猜错会让跳过按钮指到错误的位置,比拿不到数据更糟。 -// 匹配不到就返回空串,让 auto 回落到 TheIntroDB。 -// -// 西文关键词要求词边界(前后不能是字母),否则 "Options" 会被当成片头(op)、 -// "Weekend" 会被当成片尾(end)。中日文关键词按子串匹配(CJK 不是 [a-z],所以 -// 同一条边界规则对它们天然成立)。 -var chapterKindPatterns = []struct { - kind string - pattern *regexp.Regexp -}{ - {model.SegmentKindIntro, regexp.MustCompile(`(?i)(^|[^a-z])(intro|opening|op|ncop)([^a-z]|$)|片头|オープニング`)}, - - {model.SegmentKindRecap, regexp.MustCompile(`(?i)(^|[^a-z])(recap|previously)([^a-z]|$)|前情|回顾|あらすじ`)}, - - {model.SegmentKindCredits, regexp.MustCompile(`(?i)(^|[^a-z])(credits|ending|end|outro|nced|ed)([^a-z]|$)|片尾|エンディング`)}, - - {model.SegmentKindPreview, regexp.MustCompile(`(?i)(^|[^a-z])(preview|next\s+episode|next\s+time)([^a-z]|$)|预告|次回|予告`)}, -} - -// chapterTitleKind 返回章节标题对应的片段类型;识别不出来时返回空串。 -func chapterTitleKind(title string) string { - title = strings.TrimSpace(title) - if title == "" { - return "" - } - for _, entry := range chapterKindPatterns { - if entry.pattern.MatchString(title) { - return entry.kind - } - } - return "" -} - -// chaptersToSegments 把内嵌章节映射成可跳过的区间。 -// -// 只有标题能识别出类型时才产出区间;EndMs 为 0 的章节落成 0(= 延续到片尾), -// 与提供方契约一致,由客户端按媒体总时长补齐。 -func chaptersToSegments(chapters []ProbeChapter) []model.MediaSegment { - rows := make([]model.MediaSegment, 0, 4) - for _, chapter := range chapters { - kind := chapterTitleKind(chapter.Title) - if kind == "" || chapter.StartMs < 0 { - continue - } - if chapter.EndMs > 0 && chapter.EndMs-chapter.StartMs < chapterMinMs { - continue - } - rows = append(rows, model.MediaSegment{ - Kind: kind, - StartMs: chapter.StartMs, - EndMs: chapter.EndMs, - Source: SegmentSourceFFprobe, - }) - } - return rows -} diff --git a/internal/service/media_probe_chapters_test.go b/internal/service/media_probe_chapters_test.go deleted file mode 100644 index bd9099d..0000000 --- a/internal/service/media_probe_chapters_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package service - -import ( - "testing" - - "github.com/truewhile/MeBox/internal/model" -) - -// 章节标题只有「有语义」时才认。西文关键词要词边界,否则 Options / Weekend -// 这类标题会被误判成片头/片尾;中文日文按子串匹配。 -func TestChapterTitleKind(t *testing.T) { - cases := []struct { - title string - want string - }{ - {"Intro", model.SegmentKindIntro}, - {"Opening", model.SegmentKindIntro}, - {"Opening Title", model.SegmentKindIntro}, - {"OP", model.SegmentKindIntro}, - {"NCOP", model.SegmentKindIntro}, - {"片头", model.SegmentKindIntro}, - {"オープニング", model.SegmentKindIntro}, - - {"Recap", model.SegmentKindRecap}, - {"Previously on", model.SegmentKindRecap}, - {"前情提要", model.SegmentKindRecap}, - {"回顾", model.SegmentKindRecap}, - {"あらすじ", model.SegmentKindRecap}, - - {"End Credits", model.SegmentKindCredits}, - {"Credits", model.SegmentKindCredits}, - {"Ending", model.SegmentKindCredits}, - {"ED", model.SegmentKindCredits}, - {"Outro", model.SegmentKindCredits}, - {"片尾", model.SegmentKindCredits}, - {"エンディング", model.SegmentKindCredits}, - - {"Next Episode", model.SegmentKindPreview}, - {"Next time", model.SegmentKindPreview}, - {"Preview", model.SegmentKindPreview}, - {"预告", model.SegmentKindPreview}, - {"次回予告", model.SegmentKindPreview}, - - // 无语义或容易误判的标题一律不认:猜错比没有数据更糟。 - {"", ""}, - {" ", ""}, - {"Chapter 01", ""}, - {"Chapter 12", ""}, - {"Options", ""}, - {"Weekend", ""}, - {"Main Menu", ""}, - {"Menu", ""}, - } - for _, tc := range cases { - if got := chapterTitleKind(tc.title); got != tc.want { - t.Errorf("chapterTitleKind(%q) = %q, want %q", tc.title, got, tc.want) - } - } -} - -func TestChaptersToSegmentsOnlyKeepsSemanticChapters(t *testing.T) { - rows := chaptersToSegments([]ProbeChapter{ - {Index: 0, StartMs: 0, EndMs: 95_000, Title: "Chapter 01"}, - {Index: 1, StartMs: 228_664, EndMs: 246_143, Title: "Opening"}, - {Index: 2, StartMs: 246_500, EndMs: 247_400, Title: "Recap"}, // 不足 chapterMinMs - {Index: 3, StartMs: 3_431_000, EndMs: 0, Title: "End Credits"}, - }) - if len(rows) != 2 { - t.Fatalf("rows = %#v, want 2 (Opening + End Credits)", rows) - } - if rows[0].Kind != model.SegmentKindIntro || rows[0].StartMs != 228_664 || rows[0].EndMs != 246_143 { - t.Fatalf("first row = %#v", rows[0]) - } - if rows[1].Kind != model.SegmentKindCredits || rows[1].StartMs != 3_431_000 || rows[1].EndMs != 0 { - t.Fatalf("second row = %#v", rows[1]) - } - for _, row := range rows { - if row.Source != SegmentSourceFFprobe { - t.Fatalf("source = %q, want %q", row.Source, SegmentSourceFFprobe) - } - } -} - -func TestChaptersToSegmentsIgnoresUntitledAndNegativeChapters(t *testing.T) { - rows := chaptersToSegments([]ProbeChapter{ - {Index: 0, StartMs: 0, EndMs: 60_000, Title: ""}, - {Index: 1, StartMs: -1, EndMs: 60_000, Title: "Intro"}, - }) - if len(rows) != 0 { - t.Fatalf("rows = %#v, want none", rows) - } -} - -func TestNormalizeSegmentSourceFallsBackToAuto(t *testing.T) { - cases := map[string]string{ - "": SegmentSourceAuto, - " ": SegmentSourceAuto, - "auto": SegmentSourceAuto, - "AUTO": SegmentSourceAuto, - "ffprobe": SegmentSourceFFprobe, - " FFprobe ": SegmentSourceFFprobe, - "theintrodb": SegmentSourceTheIntroDB, - "TheIntroDB": SegmentSourceTheIntroDB, - "aniskip": SegmentSourceAuto, // 未知值按 auto 处理 - } - for input, want := range cases { - if got := NormalizeSegmentSource(input); got != want { - t.Errorf("NormalizeSegmentSource(%q) = %q, want %q", input, got, want) - } - } -} diff --git a/internal/service/media_probe_test.go b/internal/service/media_probe_test.go index f8e4327..f0775af 100644 --- a/internal/service/media_probe_test.go +++ b/internal/service/media_probe_test.go @@ -110,7 +110,7 @@ func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) { t.Fatal("condition was not met before the deadline") } -func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) { +func TestMediaProbeRunPersistsProbeRow(t *testing.T) { svc, repos, m := newProbeFixture(t) prober := &stubProber{result: chapterProbeResult()} svc.probe = prober @@ -123,17 +123,6 @@ func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) { t.Fatalf("probe source = %q, want the local path %q", got, m.Path) } - rows, err := repos.MediaSegment.ListByMediaSource(t.Context(), m.ID, SegmentSourceFFprobe) - if err != nil { - t.Fatal(err) - } - if len(rows) != 2 { - t.Fatalf("segments = %#v, want the 2 semantic chapters", rows) - } - if rows[0].Kind != model.SegmentKindIntro || rows[0].SeriesID != "s-1" { - t.Fatalf("segment[0] = %#v, want an intro carrying the series id", rows[0]) - } - probe, err := repos.MediaProbe.Get(t.Context(), m.ID) if err != nil { t.Fatal(err) @@ -170,7 +159,7 @@ func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) { } } -func TestMediaProbeFailureKeepsPreviousProbeAndSegments(t *testing.T) { +func TestMediaProbeFailureKeepsPreviousSummary(t *testing.T) { svc, repos, m := newProbeFixture(t) prober := &stubProber{result: chapterProbeResult()} svc.probe = prober @@ -186,17 +175,11 @@ func TestMediaProbeFailureKeepsPreviousProbeAndSegments(t *testing.T) { if probe == nil || probe.LastError == "" { t.Fatal("a failed re-probe must record the error") } - // 关键:失败只更新时间与错误信息,上一次成功的媒体信息与片段必须留着。 - if probe.DurationSec != 1451 || probe.Container != "matroska,webm" { + // 关键:失败只更新时间与错误信息,上一次成功的媒体信息必须留着—— + // 否则一次失败的重探会把已经拿到的时长(片尾区间换算要用)抹掉。 + if probe.DurationSec != 1451 || probe.Container != "matroska,webm" || probe.Payload == "" { t.Fatalf("a failed re-probe wiped the previous summary: %#v", probe) } - rows, err := repos.MediaSegment.ListByMediaSource(t.Context(), m.ID, SegmentSourceFFprobe) - if err != nil { - t.Fatal(err) - } - if len(rows) != 2 { - t.Fatalf("segments = %#v, want the previous 2 kept", rows) - } } // 探测有结论后就不再重探;失败要等冷却期过去才允许重试。 diff --git a/internal/service/media_segment.go b/internal/service/media_segment.go index e3e3536..3e53031 100644 --- a/internal/service/media_segment.go +++ b/internal/service/media_segment.go @@ -71,82 +71,53 @@ func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentServic return s } -// SegmentsResult 是播放器一次查询的结果。 -type SegmentsResult struct { - // Segments 是按当前来源选定、可以直接用来跳过的区间。 - Segments []model.MediaSegment - // Pending 为 true 表示 ffprobe 提取还在后台跑:这次可能还没有章节数据, - // 客户端过几秒再拉一次就能拿到;那时如果片头还没播完,跳过按钮会自动出现。 - Pending bool -} - -// SegmentsForPlayback 返回播放器该用的片段,并按需触发数据补齐。 +// ListForPlayback returns the segments known for a media item, refreshing from +// the provider when the cache is stale. // -// 它绝不做阻塞起播的事:TheIntroDB 的抓取沿用原来的「以调用方 deadline 为预算」, -// ffprobe 提取则完全异步。抓取或提取失败只是少一个「跳过片头」按钮、或晚几秒 -// 出现,绝不能让播放报错。 -func (s *MediaSegmentService) SegmentsForPlayback(ctx context.Context, m *model.Media, source string) (SegmentsResult, error) { +// 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的, +// 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。 +// +// 时间轴数据只来自 TheIntroDB。顺带在返回前起一次异步探测补齐媒体信息(主要是 +// STRM 媒体的时长),但探测结果不参与片段判定,详见 MediaProbeService 的说明。 +func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) { if s == nil || s.repo == nil || m == nil || m.ID == "" { - return SegmentsResult{}, nil + return nil, nil } - source = NormalizeSegmentSource(source) - result := SegmentsResult{} - // 只用社区库时连探测都不该触发:没必要为一次用不上的章节提取去跑 ffprobe。 - var chapterRows []model.MediaSegment - needsProbe := false - if source != SegmentSourceTheIntroDB { - chapterRows, needsProbe = s.chapterSegments(ctx, m) - } - result.Pending = needsProbe - // 用 defer 保证无论走哪条分支、包括中途出错,后台提取都会排上队;同时它一定在 - // 所有前台数据库读写之后才启动(见 triggerAsyncProbe 的说明)。 - defer triggerAsyncProbe(s, m, needsProbe) - - if source == SegmentSourceTheIntroDB { - introRows, err := s.introDBSegments(ctx, m) - if err != nil { - return result, err - } - result.Segments = introRows - return result, nil - } - // ffprobe 档、以及 auto 档下已经有可用章节的情况,都整体采用章节数据。 - // - // 章节与社区库的数据刻意不合并:两边对同一集的判定会互相矛盾(同一集的片尾 - // 起点能差上百秒),只能按 media 整体二选一。auto 走到这里说明章节可用,也就 - // 不必再去打一次用不上的社区库。 - if source == SegmentSourceFFprobe || len(chapterRows) > 0 { - result.Segments = chapterRows - return result, nil - } - // auto 且没有可用章节:回落到社区库;Pending 保留,客户端会再拉一次。 - introRows, err := s.introDBSegments(ctx, m) - if err != nil { - return result, err - } - result.Segments = introRows - return result, nil + // 用 defer 保证探测一定在本次请求所有数据库读写之后才启动:后台探测自己也要 + // 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是 + // SQLITE_BUSY,实测能直接把社区库的落库打失败)。 + defer s.ensureMediaProbe(ctx, m) + return s.introDBSegments(ctx, m) } -// triggerAsyncProbe 在所有前台数据库读写都结束之后再起后台提取。 +// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。 // -// 顺序很关键:后台探测自己也要写库,若在本次请求的写事务还没结束时启动,两个写 -// 事务会抢同一把锁(SQLite 下就是 SQLITE_BUSY)。 -func triggerAsyncProbe(s *MediaSegmentService, m *model.Media, needsProbe bool) { - if !needsProbe || s == nil || s.probe == nil { +// 它只为「补齐媒体信息」服务:失败只是拿不到时长,不影响播放,也不影响片段。 +func (s *MediaSegmentService) ensureMediaProbe(ctx context.Context, m *model.Media) { + if s == nil || s.probe == nil || m == nil { + return + } + cached, err := s.repo.MediaProbe.Get(ctx, m.ID) + if err != nil { + s.debug("get media probe failed", m.ID, err) + return + } + if mediaProbeSettled(cached) { return } s.probe.EnsureAsync(m) } -// ListForPlayback 供 Emby / Jellyfin 兼容接口使用:按 auto 档取数据(章节优先, -// 回落社区库)。第三方客户端不会轮询,所以这里只返回当前能拿到的部分。 -func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) { - result, err := s.SegmentsForPlayback(ctx, m, SegmentSourceAuto) - if err != nil { - return nil, err +// mediaProbeSettled 判断这部媒体的探测是否已经「有结论」——成功过,或者失败但还在 +// 冷却期内。有结论就不必再探;失败且已过冷却期时返回 false,让下一次播放重试。 +func mediaProbeSettled(row *model.MediaProbe) bool { + if row == nil { + return false } - return result.Segments, nil + if strings.TrimSpace(row.LastError) == "" { + return true + } + return time.Since(row.ProbedAt) < mediaProbeFailureRetry } // introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。 @@ -174,45 +145,6 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi return refreshed, nil } -// chapterSegments 读 ffprobe 提取出的章节区间,并报告「是否还需要等一次提取结果」。 -// -// 它只读、不启动提取:调用方要等所有前台数据库读写都结束之后再起后台任务,否则 -// 后台写事务会和本次请求的写事务抢同一把锁。探测失败的结果也会落库,所以不会 -// 每次播放都为同一个坏源重跑。 -func (s *MediaSegmentService) chapterSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, bool) { - if s == nil || s.probe == nil || s.repo == nil { - return nil, false - } - rows, err := s.repo.MediaSegment.ListByMediaSource(ctx, m.ID, SegmentSourceFFprobe) - if err != nil { - s.debug("list ffprobe segments failed", m.ID, err) - return nil, false - } - cached, err := s.repo.MediaProbe.Get(ctx, m.ID) - if err != nil { - s.debug("get media probe failed", m.ID, err) - return nil, false - } - if mediaProbeSettled(cached) { - return rows, false - } - // 还没探过、或失败已过冷却期:值得让客户端稍后再来一次。 - return rows, true -} - -// mediaProbeSettled 判断这部媒体的探测是否已经「有结论」——成功过,或者失败但还在 -// 冷却期内。有结论就不必再探,客户端也不用继续轮询;失败且已过冷却期时返回 -// false,让下一次播放重试。 -func mediaProbeSettled(row *model.MediaProbe) bool { - if row == nil { - return false - } - if strings.TrimSpace(row.LastError) == "" { - return true - } - return time.Since(row.ProbedAt) < mediaProbeFailureRetry -} - func (s *MediaSegmentService) debug(message, mediaID string, err error) { if s == nil || s.log == nil { return diff --git a/internal/service/media_segment_probe_test.go b/internal/service/media_segment_probe_test.go new file mode 100644 index 0000000..fd21178 --- /dev/null +++ b/internal/service/media_segment_probe_test.go @@ -0,0 +1,77 @@ +package service + +import ( + "testing" + "time" + + "go.uber.org/zap" +) + +// 播放请求要顺带把媒体信息(主要是 STRM 媒体的时长)补齐,但探测不能影响片段: +// ListForPlayback 只读社区库,探测在返回前才异步排上队。 +func TestListForPlaybackTriggersMediaProbe(t *testing.T) { + probeSvc, repos, m := newProbeFixture(t) + prober := &stubProber{result: chapterProbeResult()} + probeSvc.probe = prober + + segments := NewMediaSegmentService(zap.NewNop(), repos).SetProbe(probeSvc) + + rows, err := segments.ListForPlayback(t.Context(), m) + if err != nil { + t.Fatalf("ListForPlayback: %v", err) + } + if len(rows) != 0 { + t.Fatalf("rows = %#v, want none without a provider", rows) + } + + // 探测是异步的:等它跑完并落库。 + waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() >= 1 }) + probeRow, err := repos.MediaProbe.Get(t.Context(), m.ID) + if err != nil { + t.Fatal(err) + } + if probeRow == nil { + t.Fatal("the playback path should have probed the media info") + } + + // 已经有结论的媒体不该被反复探测:每次播放重跑一次 2~4.5 秒的远端读取太贵。 + if _, err := segments.ListForPlayback(t.Context(), m); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + if got := prober.callCount(); got != 1 { + t.Fatalf("probe calls = %d, want 1 (a settled probe must not repeat)", got) + } +} + +// 探测失败也要有结论:冷却期内不再重探。 +func TestListForPlaybackDoesNotReprobeWithinFailureCooldown(t *testing.T) { + probeSvc, repos, m := newProbeFixture(t) + prober := &stubProber{result: chapterProbeResult()} + probeSvc.probe = prober + if err := repos.MediaProbe.MarkFailure(t.Context(), m.ID, "boom", time.Now()); err != nil { + t.Fatal(err) + } + + segments := NewMediaSegmentService(zap.NewNop(), repos).SetProbe(probeSvc) + if _, err := segments.ListForPlayback(t.Context(), m); err != nil { + t.Fatal(err) + } + time.Sleep(50 * time.Millisecond) + if got := prober.callCount(); got != 0 { + t.Fatalf("probe calls = %d, want 0 inside the cooldown", got) + } +} + +// 没注入探测服务时(ffprobe 不可用的精简部署)播放链路必须照常工作。 +func TestListForPlaybackWithoutProbeStillWorks(t *testing.T) { + _, repos, m := newProbeFixture(t) + segments := NewMediaSegmentService(zap.NewNop(), repos) + rows, err := segments.ListForPlayback(t.Context(), m) + if err != nil { + t.Fatalf("ListForPlayback: %v", err) + } + if len(rows) != 0 { + t.Fatalf("rows = %#v, want none", rows) + } +} diff --git a/internal/service/media_segment_source_test.go b/internal/service/media_segment_source_test.go deleted file mode 100644 index 63fd040..0000000 --- a/internal/service/media_segment_source_test.go +++ /dev/null @@ -1,223 +0,0 @@ -package service - -import ( - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "sync/atomic" - "testing" - "time" - - "go.uber.org/zap" - - "github.com/truewhile/MeBox/internal/model" - "github.com/truewhile/MeBox/internal/repository" -) - -// segmentSourceFixture 提供一个带社区库假服务端、可选探测桩、以及一条已有本地 -// 文件的媒体行,用来验证「数据来源选择」这一层。 -type segmentSourceFixture struct { - svc *MediaSegmentService - repos *repository.Container - calls *int32 - prober *stubProber - media *model.Media -} - -func newSegmentSourceFixture(t *testing.T, prober *stubProber) *segmentSourceFixture { - t.Helper() - repos := repository.New(newServiceTestDB(t)) - var calls int32 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - atomic.AddInt32(&calls, 1) - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(introDBMoviePayload)) - })) - t.Cleanup(server.Close) - - svc := NewMediaSegmentService(zap.NewNop(), repos). - SetIntroDB(NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)) - - dir := t.TempDir() - path := filepath.Join(dir, "inception.mkv") - if err := os.WriteFile(path, []byte("data"), 0o600); err != nil { - t.Fatal(err) - } - m := &model.Media{Base: model.Base{ID: "mv-1"}, Title: "盗梦空间", Path: path, TMDbID: 27205} - if err := repos.DB.Create(m).Error; err != nil { - t.Fatal(err) - } - - if prober != nil { - probeSvc := NewMediaProbeService(zap.NewNop(), repos, nil) - probeSvc.probe = prober - svc.SetProbe(probeSvc) - } - return &segmentSourceFixture{svc: svc, repos: repos, calls: &calls, prober: prober, media: m} -} - -// seedChapters 模拟「已经提取过并且拿到了章节」:写入章节区间 + 探测行(探测行 -// 是「已经探过」的标记,客户端靠它停止轮询)。 -func (f *segmentSourceFixture) seedChapters(t *testing.T, rows ...model.MediaSegment) { - t.Helper() - seeded := make([]model.MediaSegment, 0, len(rows)) - for _, row := range rows { - row.MediaID = f.media.ID - row.Source = SegmentSourceFFprobe - seeded = append(seeded, row) - } - if err := f.repos.MediaSegment.ReplaceForMedia(t.Context(), f.media.ID, SegmentSourceFFprobe, seeded); err != nil { - t.Fatal(err) - } - if err := f.repos.MediaProbe.Upsert(t.Context(), &model.MediaProbe{ - MediaID: f.media.ID, - ProbedAt: time.Now(), - ChapterCount: len(seeded), - }); err != nil { - t.Fatal(err) - } -} - -func chapterIntroRow() model.MediaSegment { - return model.MediaSegment{Kind: model.SegmentKindIntro, StartMs: 228_664, EndMs: 246_143} -} - -// auto 档在有章节时必须整体采用章节,并且不打社区库。 -func TestSegmentsForPlaybackAutoPrefersChapters(t *testing.T) { - f := newSegmentSourceFixture(t, &stubProber{result: chapterProbeResult()}) - f.seedChapters(t, chapterIntroRow()) - - for _, source := range []string{SegmentSourceAuto, SegmentSourceFFprobe, "未知值"} { - result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, source) - if err != nil { - t.Fatalf("source %q: %v", source, err) - } - if len(result.Segments) != 1 || result.Segments[0].StartMs != 228_664 { - t.Fatalf("source %q segments = %#v, want the chapter row", source, result.Segments) - } - if result.Segments[0].Source != SegmentSourceFFprobe { - t.Fatalf("source %q returned a %q row", source, result.Segments[0].Source) - } - if result.Pending { - t.Fatalf("source %q reported pending although the probe is cached", source) - } - } - if got := atomic.LoadInt32(f.calls); got != 0 { - t.Fatalf("provider calls = %d, want 0: chapters must not trigger TheIntroDB", got) - } -} - -// theintrodb 档必须忽略章节,并且不触发任何探测。 -func TestSegmentsForPlaybackIntroDBOnlyIgnoresChapters(t *testing.T) { - prober := &stubProber{result: chapterProbeResult()} - f := newSegmentSourceFixture(t, prober) - f.seedChapters(t, chapterIntroRow()) - - result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceTheIntroDB) - if err != nil { - t.Fatal(err) - } - if len(result.Segments) != 1 || result.Segments[0].Source != IntroDBSource { - t.Fatalf("segments = %#v, want the community row", result.Segments) - } - if result.Segments[0].StartMs != 0 || result.Segments[0].EndMs != 38_000 { - t.Fatalf("segments = %#v, want the movie intro from the provider", result.Segments) - } - if result.Pending { - t.Fatal("theintrodb-only must never report pending") - } - // 只用社区库时不该为一次用不上的章节提取去跑 3 秒 ffprobe。 - if got := prober.callCount(); got != 0 { - t.Fatalf("probe calls = %d, want 0", got) - } -} - -// auto 档没有章节时回落到社区库,同时告诉客户端「章节还在提取」。 -func TestSegmentsForPlaybackAutoFallsBackAndReportsPending(t *testing.T) { - prober := &stubProber{result: chapterProbeResult()} - f := newSegmentSourceFixture(t, prober) - - result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceAuto) - if err != nil { - t.Fatal(err) - } - if len(result.Segments) != 1 || result.Segments[0].Source != IntroDBSource { - t.Fatalf("segments = %#v, want the community fallback", result.Segments) - } - if !result.Pending { - t.Fatal("auto without chapters must report that a probe is running") - } - // 异步提取确实排上了队(这里只验证调度;落库由 media_probe_test.go 覆盖)。 - waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() >= 1 }) -} - -// ffprobe 档没有章节数据时返回空,但会起一次提取并报告 pending。 -func TestSegmentsForPlaybackFFprobeOnlyReturnsEmptyWhilePending(t *testing.T) { - prober := &stubProber{result: chapterProbeResult()} - f := newSegmentSourceFixture(t, prober) - - result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceFFprobe) - if err != nil { - t.Fatal(err) - } - if len(result.Segments) != 0 { - t.Fatalf("segments = %#v, want none before extraction finishes", result.Segments) - } - if !result.Pending { - t.Fatal("ffprobe-only must report pending while the extraction runs") - } - if got := atomic.LoadInt32(f.calls); got != 0 { - t.Fatalf("provider calls = %d, want 0", got) - } -} - -// 提取失败也要有个结果:不能在每次播放时无限重试,客户端也不该一直轮询。 -func TestSegmentsForPlaybackStopsPendingAfterFailedProbe(t *testing.T) { - prober := &stubProber{result: chapterProbeResult()} - f := newSegmentSourceFixture(t, prober) - if err := f.repos.MediaProbe.MarkFailure(t.Context(), f.media.ID, "probe exploded", time.Now()); err != nil { - t.Fatal(err) - } - - result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceFFprobe) - if err != nil { - t.Fatal(err) - } - if result.Pending { - t.Fatal("a recent failure must stop the client from polling") - } - if got := prober.callCount(); got != 0 { - t.Fatalf("probe calls = %d, want 0 inside the failure cooldown", got) - } -} - -// 没有注入探测服务时(ffprobe 不可用的精简部署)不能谎报 pending。 -func TestSegmentsForPlaybackWithoutProbeNeverReportsPending(t *testing.T) { - f := newSegmentSourceFixture(t, nil) - - result, err := f.svc.SegmentsForPlayback(t.Context(), f.media, SegmentSourceFFprobe) - if err != nil { - t.Fatal(err) - } - if result.Pending { - t.Fatal("pending must be false when no prober is wired") - } - if len(result.Segments) != 0 { - t.Fatalf("segments = %#v, want none", result.Segments) - } -} - -// ListForPlayback 仍供 Emby / Jellyfin 兼容接口使用,按 auto 档取数。 -func TestListForPlaybackUsesAutoSelection(t *testing.T) { - f := newSegmentSourceFixture(t, &stubProber{result: chapterProbeResult()}) - f.seedChapters(t, chapterIntroRow()) - - rows, err := f.svc.ListForPlayback(t.Context(), f.media) - if err != nil { - t.Fatal(err) - } - if len(rows) != 1 || rows[0].Source != SegmentSourceFFprobe { - t.Fatalf("rows = %#v, want the chapter row via auto", rows) - } -} diff --git a/internal/service/play_profile.go b/internal/service/play_profile.go index 4c3d5a3..d182809 100644 --- a/internal/service/play_profile.go +++ b/internal/service/play_profile.go @@ -94,7 +94,6 @@ func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (* PreferredAudioLang: in.PreferredAudioLang, AutoplayNext: in.AutoplayNext, SkipIntro: in.SkipIntro, - SegmentSource: NormalizeSegmentSource(in.SegmentSource), AllowedLibraryIDs: string(libsBlob), } if in.RequirePIN && in.PIN != "" { @@ -154,7 +153,6 @@ func (s *PlayProfileService) updateExisting(ctx context.Context, row *model.Play "preferred_audio_lang": in.PreferredAudioLang, "autoplay_next": in.AutoplayNext, "skip_intro": in.SkipIntro, - "segment_source": NormalizeSegmentSource(in.SegmentSource), "allowed_library_ids": string(libsBlob), } if in.RequirePIN && in.PIN != "" { diff --git a/internal/service/play_profile_model.go b/internal/service/play_profile_model.go index 7e910f9..77f8908 100644 --- a/internal/service/play_profile_model.go +++ b/internal/service/play_profile_model.go @@ -13,20 +13,18 @@ import ( // PlayProfileInput is the create/update payload accepted by the API. // PIN is hashed only when non-empty so omitting it preserves the existing PIN on update. type PlayProfileInput struct { - UserID string `json:"user_id"` - Name string `json:"name"` - IsDefault bool `json:"is_default"` - ContentRatingLimit string `json:"content_rating_limit"` - AllowAdult bool `json:"allow_adult"` - RequirePIN bool `json:"require_pin"` - PIN string `json:"pin,omitempty"` - PreferredSubtitleLang string `json:"preferred_subtitle_lang"` - PreferredAudioLang string `json:"preferred_audio_lang"` - AutoplayNext bool `json:"autoplay_next"` - SkipIntro bool `json:"skip_intro"` - // SegmentSource 是片头/片尾数据来源:auto | theintrodb | ffprobe。 - SegmentSource string `json:"segment_source"` - AllowedLibraryIDs []string `json:"allowed_library_ids"` + UserID string `json:"user_id"` + Name string `json:"name"` + IsDefault bool `json:"is_default"` + ContentRatingLimit string `json:"content_rating_limit"` + AllowAdult bool `json:"allow_adult"` + RequirePIN bool `json:"require_pin"` + PIN string `json:"pin,omitempty"` + PreferredSubtitleLang string `json:"preferred_subtitle_lang"` + PreferredAudioLang string `json:"preferred_audio_lang"` + AutoplayNext bool `json:"autoplay_next"` + SkipIntro bool `json:"skip_intro"` + AllowedLibraryIDs []string `json:"allowed_library_ids"` } // ProfileView is the public shape for React forms. diff --git a/web/src/api/play_profiles.ts b/web/src/api/play_profiles.ts index 8899635..158a73c 100644 --- a/web/src/api/play_profiles.ts +++ b/web/src/api/play_profiles.ts @@ -1,5 +1,5 @@ import { api } from './client' -import type { PlayProfile, SegmentSource } from '../types' +import type { PlayProfile } from '../types' // Payload accepted by create / update. export interface PlayProfileInput { @@ -14,7 +14,6 @@ export interface PlayProfileInput { preferred_audio_lang?: string autoplay_next: boolean skip_intro: boolean - segment_source: SegmentSource allowed_library_ids: string[] } diff --git a/web/src/pages/PlayerPage.tsx b/web/src/pages/PlayerPage.tsx index 5bc33f6..4890ad2 100644 --- a/web/src/pages/PlayerPage.tsx +++ b/web/src/pages/PlayerPage.tsx @@ -75,13 +75,6 @@ type PlaybackProgressSession = { // 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。 const SKIP_NOTICE_MS = 6000 -// ffprobe 章节提取在服务端是异步的:pending 为 true 时按这个间隔重试,最多这么 -// 多次(远端探测实测 3~4 秒,本地更快;40 秒的预算足以覆盖慢速 CDN 与大文件)。 -// 重试期间播放完全不受影响;拿到数据时如果片头已经播过去了,resolveActiveSkip -// 自然不会再提示,不会出现「点一下就跳过头」的按钮。 -const SKIP_SEGMENTS_POLL_MS = 4000 -const SKIP_SEGMENTS_MAX_POLLS = 10 - function normalizePlayerVolume(value: unknown): number { const parsed = Number(value) if (!Number.isFinite(parsed)) return 1 @@ -739,7 +732,6 @@ export function PlayerPage() { useEffect(() => { if (!mediaId) return let cancelled = false - let pollTimer: ReturnType | null = null setRawSkipSegments([]) setAutoSkipIntro(false) setActiveSkip(null) @@ -747,28 +739,16 @@ export function PlayerPage() { setDismissedSkipKinds([]) setAutoSuppressedKinds([]) // 片段数据与播放来源无关,播放开始后异步补抓即可,绝不挡在起播路径上。 - // - // 服务端的 ffprobe 章节提取是异步的:pending 为 true 说明这次还没结果,隔几秒 - // 再拉一次。提取完成后如果片头还没播完,跳过按钮会自己出现;已经过了片头时间 - // 的话 resolveActiveSkip 不会提示,所以不会出现「点一下就跳过头」的按钮。 - const load = (attempt: number) => { - playbackAPI - .segments(mediaId) - .then((res) => { - if (cancelled) return - setAutoSkipIntro(Boolean(res.auto_skip)) - setRawSkipSegments(res.segments ?? []) - const hasSegments = (res.segments ?? []).length > 0 - if (res.pending && !hasSegments && attempt < SKIP_SEGMENTS_MAX_POLLS) { - pollTimer = setTimeout(() => load(attempt + 1), SKIP_SEGMENTS_POLL_MS) - } - }) - .catch(() => undefined) - } - load(0) + playbackAPI + .segments(mediaId) + .then((res) => { + if (cancelled) return + setAutoSkipIntro(Boolean(res.auto_skip)) + setRawSkipSegments(res.segments ?? []) + }) + .catch(() => undefined) return () => { cancelled = true - if (pollTimer) clearTimeout(pollTimer) } }, [mediaId]) diff --git a/web/src/pages/ProfileFormModal.tsx b/web/src/pages/ProfileFormModal.tsx index fb54aec..3eca77c 100644 --- a/web/src/pages/ProfileFormModal.tsx +++ b/web/src/pages/ProfileFormModal.tsx @@ -37,7 +37,6 @@ export function ProfileFormModal({ preferred_audio_lang: editing?.preferred_audio_lang ?? '', autoplay_next: editing?.autoplay_next ?? true, skip_intro: editing?.skip_intro ?? false, - segment_source: editing?.segment_source ?? 'auto', allowed_library_ids: editing?.allowed_library_ids ?? [], })) const [saving, setSaving] = useState(false) diff --git a/web/src/pages/ProfileFormSections.tsx b/web/src/pages/ProfileFormSections.tsx index d55bcaa..41de151 100644 --- a/web/src/pages/ProfileFormSections.tsx +++ b/web/src/pages/ProfileFormSections.tsx @@ -126,17 +126,6 @@ export function ProfilePreferenceFields({ checked={form.skip_intro} onChange={(value) => update({ skip_intro: value })} /> - - - ) } diff --git a/web/src/types/playProfiles.ts b/web/src/types/playProfiles.ts index 028ce87..5e05a60 100644 --- a/web/src/types/playProfiles.ts +++ b/web/src/types/playProfiles.ts @@ -10,13 +10,9 @@ export interface PlayProfile { preferred_audio_lang?: string autoplay_next: boolean skip_intro: boolean - segment_source?: SegmentSource allowed_library_ids: string[] total_watch_time: number last_active_at?: string created_at: string updated_at: string } - -/** 片头/片尾数据来源:auto 优先文件内嵌章节,没有则回落 TheIntroDB。 */ -export type SegmentSource = 'auto' | 'theintrodb' | 'ffprobe' diff --git a/web/src/types/playback.ts b/web/src/types/playback.ts index 96a15fb..83558e4 100644 --- a/web/src/types/playback.ts +++ b/web/src/types/playback.ts @@ -43,16 +43,5 @@ export interface PlaybackSegmentsResponse { segments: PlaybackSegment[] /** 当前生效播放档案的「自动跳过片头」开关。 */ auto_skip: boolean - /** - * true 表示 ffprobe 章节提取还在服务端后台跑:这次可能还没有章节数据,隔几秒 - * 再拉一次就能拿到。拿到时若片头还没播完,跳过按钮会自动出现;已经过了片头 - * 时间则不会提示。 - */ - pending?: boolean - /** 本次实际生效的数据来源。 */ - source?: PlaybackSegmentSource } -/** 片头/片尾数据来源,与后端 play_profiles.segment_source 一致。 */ -export type PlaybackSegmentSource = 'auto' | 'theintrodb' | 'ffprobe' -