mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 03:06:38 +08:00
优化
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user