mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
优化
This commit is contained in:
@@ -22,9 +22,7 @@ import (
|
|||||||
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
autoSkip := resolveAutoSkipFlag(c, svc)
|
autoSkip := resolveAutoSkipFlag(c, svc)
|
||||||
source := resolveSegmentSource(c, svc)
|
|
||||||
segments := []service.SegmentView{}
|
segments := []service.SegmentView{}
|
||||||
pending := false
|
|
||||||
|
|
||||||
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
m, err := findMediaForPlaybackEndpoint(c, svc, c.Param("id"))
|
||||||
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
if err != nil || m == nil || !mediaVisibleForRequest(c, svc, m) {
|
||||||
@@ -33,22 +31,14 @@ func playbackSegmentsHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
|
// 远程 Emby 挂载的条目是上游库的投影,本地没有可查询的外部 ID 关联。
|
||||||
if svc.Segments != nil && !service.IsEmbyRemoteID(m.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 {
|
if listErr != nil && svc.Log != nil {
|
||||||
svc.Log.Debug("list media segments failed",
|
svc.Log.Debug("list media segments failed",
|
||||||
zap.String("media_id", m.ID), zap.Error(listErr))
|
zap.String("media_id", m.ID), zap.Error(listErr))
|
||||||
}
|
}
|
||||||
segments = service.ToSegmentViews(result.Segments)
|
segments = service.ToSegmentViews(rows)
|
||||||
pending = result.Pending
|
|
||||||
}
|
}
|
||||||
// pending 告诉客户端「章节提取还在后台跑,过几秒再拉一次」;提取完成时
|
c.JSON(http.StatusOK, gin.H{"segments": segments, "auto_skip": autoSkip})
|
||||||
// 如果片头还没播完,跳过按钮就会自己出现,已经过了片头则不会提示。
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
|
||||||
"segments": segments,
|
|
||||||
"auto_skip": autoSkip,
|
|
||||||
"pending": pending,
|
|
||||||
"source": source,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,13 +53,3 @@ func resolveAutoSkipFlag(c *gin.Context, svc *service.Container) bool {
|
|||||||
}
|
}
|
||||||
return profile.SkipIntro
|
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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,21 +53,18 @@ type PlaylistItem struct {
|
|||||||
// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all).
|
// AllowedLibraryIDs is a JSON array of library UUIDs (empty = all).
|
||||||
type PlayProfile struct {
|
type PlayProfile struct {
|
||||||
Base
|
Base
|
||||||
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
UserID string `gorm:"index;size:36;not null" json:"user_id"`
|
||||||
Name string `gorm:"size:64;not null" json:"name"`
|
Name string `gorm:"size:64;not null" json:"name"`
|
||||||
IsDefault bool `gorm:"default:false" json:"is_default"`
|
IsDefault bool `gorm:"default:false" json:"is_default"`
|
||||||
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
|
ContentRatingLimit string `gorm:"size:16" json:"content_rating_limit,omitempty"`
|
||||||
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
|
AllowAdult bool `gorm:"default:false" json:"allow_adult"`
|
||||||
RequirePIN bool `gorm:"default:false" json:"require_pin"`
|
RequirePIN bool `gorm:"default:false" json:"require_pin"`
|
||||||
PINHash string `gorm:"size:128" json:"-"`
|
PINHash string `gorm:"size:128" json:"-"`
|
||||||
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
|
PreferredSubtitleLang string `gorm:"size:16" json:"preferred_subtitle_lang,omitempty"`
|
||||||
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
|
PreferredAudioLang string `gorm:"size:16" json:"preferred_audio_lang,omitempty"`
|
||||||
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
|
AutoplayNext bool `gorm:"default:true" json:"autoplay_next"`
|
||||||
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
|
SkipIntro bool `gorm:"default:false" json:"skip_intro"`
|
||||||
// SegmentSource 决定片头/片尾数据从哪来:auto(优先文件内嵌章节,没有可用
|
AllowedLibraryIDs string `gorm:"type:text;default:'[]'" json:"allowed_library_ids"`
|
||||||
// 章节时回落到 TheIntroDB)/ theintrodb / ffprobe。空值按 auto 处理。
|
TotalWatchTime int64 `gorm:"default:0" json:"total_watch_time"`
|
||||||
SegmentSource string `gorm:"size:16;default:'auto'" json:"segment_source,omitempty"`
|
LastActiveAt *time.Time `json:"last_active_at,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"`
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,18 +18,7 @@ import (
|
|||||||
"github.com/truewhile/MeBox/internal/repository"
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 播放档案里可选的片头/片尾数据来源。
|
// 探测结果的缓存与预算策略。
|
||||||
const (
|
|
||||||
// SegmentSourceAuto 优先用文件内嵌章节(对这个片源最准),没有可用章节时
|
|
||||||
// 回落到 TheIntroDB。
|
|
||||||
SegmentSourceAuto = "auto"
|
|
||||||
// SegmentSourceFFprobe 只用文件内嵌章节。
|
|
||||||
SegmentSourceFFprobe = "ffprobe"
|
|
||||||
// SegmentSourceTheIntroDB 只用社区数据库。它同时是 media_segments.source 的
|
|
||||||
// 取值——两处必须一致,所以直接复用提供方的常量。
|
|
||||||
SegmentSourceTheIntroDB = IntroDBSource
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// mediaProbeTimeout 是一次后台探测的总预算(含把 strm 目标解析成直链)。
|
// mediaProbeTimeout 是一次后台探测的总预算(含把 strm 目标解析成直链)。
|
||||||
mediaProbeTimeout = 90 * time.Second
|
mediaProbeTimeout = 90 * time.Second
|
||||||
@@ -41,31 +30,25 @@ const (
|
|||||||
mediaProbeErrorLimit = 200
|
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 需要的探测能力。抽成接口是为了在测试里注入
|
// mediaProber 是 MediaProbeService 需要的探测能力。抽成接口是为了在测试里注入
|
||||||
// 桩,避免依赖真实 ffprobe 二进制。
|
// 桩,避免依赖真实 ffprobe 二进制。
|
||||||
type mediaProber interface {
|
type mediaProber interface {
|
||||||
ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error)
|
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 {
|
type MediaProbeService struct {
|
||||||
log *zap.Logger
|
log *zap.Logger
|
||||||
repo *repository.Container
|
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")
|
return ProbeInput{}, errors.New("strm probe source unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistProbe 把一次成功的探测落库:先写片段区间,再写探测行。
|
// persistProbe 把一次成功的探测落库。
|
||||||
//
|
|
||||||
// 顺序很重要:探测行是「已经探过」的标记,客户端靠它决定要不要继续轮询。先写
|
|
||||||
// 它会让客户端在区间还没落库时就停止等待。
|
|
||||||
func (s *MediaProbeService) persistProbe(ctx context.Context, m *model.Media, result *FullProbeResult) error {
|
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")
|
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()
|
payload, err := result.PayloadJSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -110,7 +110,7 @@ func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) {
|
|||||||
t.Fatal("condition was not met before the deadline")
|
t.Fatal("condition was not met before the deadline")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMediaProbeRunPersistsSegmentsAndProbeRow(t *testing.T) {
|
func TestMediaProbeRunPersistsProbeRow(t *testing.T) {
|
||||||
svc, repos, m := newProbeFixture(t)
|
svc, repos, m := newProbeFixture(t)
|
||||||
prober := &stubProber{result: chapterProbeResult()}
|
prober := &stubProber{result: chapterProbeResult()}
|
||||||
svc.probe = prober
|
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)
|
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)
|
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
svc, repos, m := newProbeFixture(t)
|
||||||
prober := &stubProber{result: chapterProbeResult()}
|
prober := &stubProber{result: chapterProbeResult()}
|
||||||
svc.probe = prober
|
svc.probe = prober
|
||||||
@@ -186,17 +175,11 @@ func TestMediaProbeFailureKeepsPreviousProbeAndSegments(t *testing.T) {
|
|||||||
if probe == nil || probe.LastError == "" {
|
if probe == nil || probe.LastError == "" {
|
||||||
t.Fatal("a failed re-probe must record the error")
|
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)
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
|
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
|
||||||
|
|||||||
@@ -71,82 +71,53 @@ func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentServic
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// SegmentsResult 是播放器一次查询的结果。
|
// ListForPlayback returns the segments known for a media item, refreshing from
|
||||||
type SegmentsResult struct {
|
// the provider when the cache is stale.
|
||||||
// Segments 是按当前来源选定、可以直接用来跳过的区间。
|
|
||||||
Segments []model.MediaSegment
|
|
||||||
// Pending 为 true 表示 ffprobe 提取还在后台跑:这次可能还没有章节数据,
|
|
||||||
// 客户端过几秒再拉一次就能拿到;那时如果片头还没播完,跳过按钮会自动出现。
|
|
||||||
Pending bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// SegmentsForPlayback 返回播放器该用的片段,并按需触发数据补齐。
|
|
||||||
//
|
//
|
||||||
// 它绝不做阻塞起播的事: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 == "" {
|
if s == nil || s.repo == nil || m == nil || m.ID == "" {
|
||||||
return SegmentsResult{}, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
source = NormalizeSegmentSource(source)
|
// 用 defer 保证探测一定在本次请求所有数据库读写之后才启动:后台探测自己也要
|
||||||
result := SegmentsResult{}
|
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
|
||||||
// 只用社区库时连探测都不该触发:没必要为一次用不上的章节提取去跑 ffprobe。
|
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
|
||||||
var chapterRows []model.MediaSegment
|
defer s.ensureMediaProbe(ctx, m)
|
||||||
needsProbe := false
|
return s.introDBSegments(ctx, m)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// triggerAsyncProbe 在所有前台数据库读写都结束之后再起后台提取。
|
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
|
||||||
//
|
//
|
||||||
// 顺序很关键:后台探测自己也要写库,若在本次请求的写事务还没结束时启动,两个写
|
// 它只为「补齐媒体信息」服务:失败只是拿不到时长,不影响播放,也不影响片段。
|
||||||
// 事务会抢同一把锁(SQLite 下就是 SQLITE_BUSY)。
|
func (s *MediaSegmentService) ensureMediaProbe(ctx context.Context, m *model.Media) {
|
||||||
func triggerAsyncProbe(s *MediaSegmentService, m *model.Media, needsProbe bool) {
|
if s == nil || s.probe == nil || m == nil {
|
||||||
if !needsProbe || s == nil || s.probe == 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
|
return
|
||||||
}
|
}
|
||||||
s.probe.EnsureAsync(m)
|
s.probe.EnsureAsync(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListForPlayback 供 Emby / Jellyfin 兼容接口使用:按 auto 档取数据(章节优先,
|
// mediaProbeSettled 判断这部媒体的探测是否已经「有结论」——成功过,或者失败但还在
|
||||||
// 回落社区库)。第三方客户端不会轮询,所以这里只返回当前能拿到的部分。
|
// 冷却期内。有结论就不必再探;失败且已过冷却期时返回 false,让下一次播放重试。
|
||||||
func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
func mediaProbeSettled(row *model.MediaProbe) bool {
|
||||||
result, err := s.SegmentsForPlayback(ctx, m, SegmentSourceAuto)
|
if row == nil {
|
||||||
if err != nil {
|
return false
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return result.Segments, nil
|
if strings.TrimSpace(row.LastError) == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return time.Since(row.ProbedAt) < mediaProbeFailureRetry
|
||||||
}
|
}
|
||||||
|
|
||||||
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
||||||
@@ -174,45 +145,6 @@ func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Medi
|
|||||||
return refreshed, nil
|
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) {
|
func (s *MediaSegmentService) debug(message, mediaID string, err error) {
|
||||||
if s == nil || s.log == nil {
|
if s == nil || s.log == nil {
|
||||||
return
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -94,7 +94,6 @@ func (s *PlayProfileService) Create(ctx context.Context, in PlayProfileInput) (*
|
|||||||
PreferredAudioLang: in.PreferredAudioLang,
|
PreferredAudioLang: in.PreferredAudioLang,
|
||||||
AutoplayNext: in.AutoplayNext,
|
AutoplayNext: in.AutoplayNext,
|
||||||
SkipIntro: in.SkipIntro,
|
SkipIntro: in.SkipIntro,
|
||||||
SegmentSource: NormalizeSegmentSource(in.SegmentSource),
|
|
||||||
AllowedLibraryIDs: string(libsBlob),
|
AllowedLibraryIDs: string(libsBlob),
|
||||||
}
|
}
|
||||||
if in.RequirePIN && in.PIN != "" {
|
if in.RequirePIN && in.PIN != "" {
|
||||||
@@ -154,7 +153,6 @@ func (s *PlayProfileService) updateExisting(ctx context.Context, row *model.Play
|
|||||||
"preferred_audio_lang": in.PreferredAudioLang,
|
"preferred_audio_lang": in.PreferredAudioLang,
|
||||||
"autoplay_next": in.AutoplayNext,
|
"autoplay_next": in.AutoplayNext,
|
||||||
"skip_intro": in.SkipIntro,
|
"skip_intro": in.SkipIntro,
|
||||||
"segment_source": NormalizeSegmentSource(in.SegmentSource),
|
|
||||||
"allowed_library_ids": string(libsBlob),
|
"allowed_library_ids": string(libsBlob),
|
||||||
}
|
}
|
||||||
if in.RequirePIN && in.PIN != "" {
|
if in.RequirePIN && in.PIN != "" {
|
||||||
|
|||||||
@@ -13,20 +13,18 @@ import (
|
|||||||
// PlayProfileInput is the create/update payload accepted by the API.
|
// 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.
|
// PIN is hashed only when non-empty so omitting it preserves the existing PIN on update.
|
||||||
type PlayProfileInput struct {
|
type PlayProfileInput struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
ContentRatingLimit string `json:"content_rating_limit"`
|
ContentRatingLimit string `json:"content_rating_limit"`
|
||||||
AllowAdult bool `json:"allow_adult"`
|
AllowAdult bool `json:"allow_adult"`
|
||||||
RequirePIN bool `json:"require_pin"`
|
RequirePIN bool `json:"require_pin"`
|
||||||
PIN string `json:"pin,omitempty"`
|
PIN string `json:"pin,omitempty"`
|
||||||
PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
|
PreferredSubtitleLang string `json:"preferred_subtitle_lang"`
|
||||||
PreferredAudioLang string `json:"preferred_audio_lang"`
|
PreferredAudioLang string `json:"preferred_audio_lang"`
|
||||||
AutoplayNext bool `json:"autoplay_next"`
|
AutoplayNext bool `json:"autoplay_next"`
|
||||||
SkipIntro bool `json:"skip_intro"`
|
SkipIntro bool `json:"skip_intro"`
|
||||||
// SegmentSource 是片头/片尾数据来源:auto | theintrodb | ffprobe。
|
AllowedLibraryIDs []string `json:"allowed_library_ids"`
|
||||||
SegmentSource string `json:"segment_source"`
|
|
||||||
AllowedLibraryIDs []string `json:"allowed_library_ids"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProfileView is the public shape for React forms.
|
// ProfileView is the public shape for React forms.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { api } from './client'
|
import { api } from './client'
|
||||||
import type { PlayProfile, SegmentSource } from '../types'
|
import type { PlayProfile } from '../types'
|
||||||
|
|
||||||
// Payload accepted by create / update.
|
// Payload accepted by create / update.
|
||||||
export interface PlayProfileInput {
|
export interface PlayProfileInput {
|
||||||
@@ -14,7 +14,6 @@ export interface PlayProfileInput {
|
|||||||
preferred_audio_lang?: string
|
preferred_audio_lang?: string
|
||||||
autoplay_next: boolean
|
autoplay_next: boolean
|
||||||
skip_intro: boolean
|
skip_intro: boolean
|
||||||
segment_source: SegmentSource
|
|
||||||
allowed_library_ids: string[]
|
allowed_library_ids: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,13 +75,6 @@ type PlaybackProgressSession = {
|
|||||||
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
|
// 自动跳过片头后,「已跳过 · 撤销」提示停留的时长。
|
||||||
const SKIP_NOTICE_MS = 6000
|
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 {
|
function normalizePlayerVolume(value: unknown): number {
|
||||||
const parsed = Number(value)
|
const parsed = Number(value)
|
||||||
if (!Number.isFinite(parsed)) return 1
|
if (!Number.isFinite(parsed)) return 1
|
||||||
@@ -739,7 +732,6 @@ export function PlayerPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mediaId) return
|
if (!mediaId) return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
let pollTimer: ReturnType<typeof setTimeout> | null = null
|
|
||||||
setRawSkipSegments([])
|
setRawSkipSegments([])
|
||||||
setAutoSkipIntro(false)
|
setAutoSkipIntro(false)
|
||||||
setActiveSkip(null)
|
setActiveSkip(null)
|
||||||
@@ -747,28 +739,16 @@ export function PlayerPage() {
|
|||||||
setDismissedSkipKinds([])
|
setDismissedSkipKinds([])
|
||||||
setAutoSuppressedKinds([])
|
setAutoSuppressedKinds([])
|
||||||
// 片段数据与播放来源无关,播放开始后异步补抓即可,绝不挡在起播路径上。
|
// 片段数据与播放来源无关,播放开始后异步补抓即可,绝不挡在起播路径上。
|
||||||
//
|
playbackAPI
|
||||||
// 服务端的 ffprobe 章节提取是异步的:pending 为 true 说明这次还没结果,隔几秒
|
.segments(mediaId)
|
||||||
// 再拉一次。提取完成后如果片头还没播完,跳过按钮会自己出现;已经过了片头时间
|
.then((res) => {
|
||||||
// 的话 resolveActiveSkip 不会提示,所以不会出现「点一下就跳过头」的按钮。
|
if (cancelled) return
|
||||||
const load = (attempt: number) => {
|
setAutoSkipIntro(Boolean(res.auto_skip))
|
||||||
playbackAPI
|
setRawSkipSegments(res.segments ?? [])
|
||||||
.segments(mediaId)
|
})
|
||||||
.then((res) => {
|
.catch(() => undefined)
|
||||||
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)
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
if (pollTimer) clearTimeout(pollTimer)
|
|
||||||
}
|
}
|
||||||
}, [mediaId])
|
}, [mediaId])
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ export function ProfileFormModal({
|
|||||||
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
|
preferred_audio_lang: editing?.preferred_audio_lang ?? '',
|
||||||
autoplay_next: editing?.autoplay_next ?? true,
|
autoplay_next: editing?.autoplay_next ?? true,
|
||||||
skip_intro: editing?.skip_intro ?? false,
|
skip_intro: editing?.skip_intro ?? false,
|
||||||
segment_source: editing?.segment_source ?? 'auto',
|
|
||||||
allowed_library_ids: editing?.allowed_library_ids ?? [],
|
allowed_library_ids: editing?.allowed_library_ids ?? [],
|
||||||
}))
|
}))
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
|||||||
@@ -126,17 +126,6 @@ export function ProfilePreferenceFields({
|
|||||||
checked={form.skip_intro}
|
checked={form.skip_intro}
|
||||||
onChange={(value) => update({ skip_intro: value })}
|
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>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,13 +10,9 @@ export interface PlayProfile {
|
|||||||
preferred_audio_lang?: string
|
preferred_audio_lang?: string
|
||||||
autoplay_next: boolean
|
autoplay_next: boolean
|
||||||
skip_intro: boolean
|
skip_intro: boolean
|
||||||
segment_source?: SegmentSource
|
|
||||||
allowed_library_ids: string[]
|
allowed_library_ids: string[]
|
||||||
total_watch_time: number
|
total_watch_time: number
|
||||||
last_active_at?: string
|
last_active_at?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 片头/片尾数据来源:auto 优先文件内嵌章节,没有则回落 TheIntroDB。 */
|
|
||||||
export type SegmentSource = 'auto' | 'theintrodb' | 'ffprobe'
|
|
||||||
|
|||||||
@@ -43,16 +43,5 @@ export interface PlaybackSegmentsResponse {
|
|||||||
segments: PlaybackSegment[]
|
segments: PlaybackSegment[]
|
||||||
/** 当前生效播放档案的「自动跳过片头」开关。 */
|
/** 当前生效播放档案的「自动跳过片头」开关。 */
|
||||||
auto_skip: boolean
|
auto_skip: boolean
|
||||||
/**
|
|
||||||
* true 表示 ffprobe 章节提取还在服务端后台跑:这次可能还没有章节数据,隔几秒
|
|
||||||
* 再拉一次就能拿到。拿到时若片头还没播完,跳过按钮会自动出现;已经过了片头
|
|
||||||
* 时间则不会提示。
|
|
||||||
*/
|
|
||||||
pending?: boolean
|
|
||||||
/** 本次实际生效的数据来源。 */
|
|
||||||
source?: PlaybackSegmentSource
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 片头/片尾数据来源,与后端 play_profiles.segment_source 一致。 */
|
|
||||||
export type PlaybackSegmentSource = 'auto' | 'theintrodb' | 'ffprobe'
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user