This commit is contained in:
truewhile
2026-09-23 17:01:08 +08:00
parent 34ccf14cda
commit 28485ed429
17 changed files with 170 additions and 687 deletions
+3 -23
View File
@@ -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)
}
+14 -17
View File
@@ -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"`
}
+15 -43
View File
@@ -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
-72
View File
@@ -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
}
@@ -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)
}
}
}
+5 -22
View File
@@ -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)
}
}
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
+35 -103
View File
@@ -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
@@ -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)
}
}
@@ -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)
}
}
-2
View File
@@ -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 != "" {
+12 -14
View File
@@ -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.
+1 -2
View File
@@ -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[]
}
+8 -28
View File
@@ -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<typeof setTimeout> | 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])
-1
View File
@@ -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)
-11
View File
@@ -126,17 +126,6 @@ export function ProfilePreferenceFields({
checked={form.skip_intro}
onChange={(value) => update({ skip_intro: value })}
/>
<Field label="片头片尾数据来源">
<select
className="input-base"
value={form.segment_source}
onChange={(event) => update({ segment_source: event.target.value as PlayProfileInput['segment_source'] })}
>
<option value="auto">自动(优先用文件内嵌章节,没有可用章节时用 TheIntroDB)</option>
<option value="theintrodb">TheIntroDB 社区数据库(依赖刮削出的 TMDb ID)</option>
<option value="ffprobe">ffprobe 本地提取(读取文件内嵌章节,不联网)</option>
</select>
</Field>
</>
)
}
-4
View File
@@ -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'
-11
View File
@@ -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'