mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c612df1b | |||
| e2cd32af53 | |||
| 28485ed429 |
@@ -24,7 +24,42 @@ func embyNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||
out, err := svc.Emby.NextUp(c.Request.Context(), userID, limit)
|
||||
// YamBy / Emby 进剧集详情会带 SeriesId 请求「本剧下一集」。
|
||||
// 忽略该参数会把全站 NextUp 第一条塞进详情页「继续播放」。
|
||||
// 注意:不要把普通 ParentId(媒体库)当成 SeriesId,否则首页 NextUp 会被滤空。
|
||||
seriesID := firstQueryValue(c, "SeriesId", "seriesId", "seriesid")
|
||||
if seriesID == "" {
|
||||
if parentID := firstQueryValue(c, "ParentId", "parentId", "parentid"); parentID != "" {
|
||||
if strings.HasPrefix(parentID, "msgo-series-") || service.IsEmbyRemoteID(parentID) {
|
||||
seriesID = parentID
|
||||
}
|
||||
}
|
||||
}
|
||||
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
embyAttachRequestTokenToMediaSources(c, out)
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
}
|
||||
|
||||
// embyShowNextUpHandler 处理 /Shows/{id}/NextUp:把路径上的剧集 ID 当作 SeriesId。
|
||||
func embyShowNextUpHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID := embyScopedUserID(c)
|
||||
if userID == "" {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
seriesID := strings.TrimSpace(c.Param("id"))
|
||||
if seriesID == "" || strings.EqualFold(seriesID, "NextUp") {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), ""))
|
||||
out, err := svc.Emby.NextUp(c.Request.Context(), userID, seriesID, limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, embyEmptyItemsPayload())
|
||||
return
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -104,8 +105,8 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||
}
|
||||
if watched {
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: false,
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -126,6 +127,49 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:用户在剧集详情页点播放、只看了几秒就退出(历史行 completed=false)后,
|
||||
// Yamby 再次进入详情页带的 NextUp 仍要指向那一集本身,否则「继续播放」会跳到下一集。
|
||||
func TestEmbyNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
|
||||
episodeIDs := map[int]string{}
|
||||
for ep := 1; ep <= 3; ep++ {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, SeriesID: "series-1", Title: "剧一",
|
||||
SeasonNum: 1, EpisodeNum: ep,
|
||||
Path: "/media/tv/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodeIDs[ep] = m.ID
|
||||
}
|
||||
|
||||
// 第 2 集播放了 3 秒后退出:有进度、未标记看完。
|
||||
if err := svc.Repo.DB.Create(&model.PlaybackHistory{
|
||||
UserID: userID, MediaID: episodeIDs[2], PositionMs: 3582, DurationMs: 1440064,
|
||||
WatchedAt: time.Now(), Completed: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
w := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-1&Limit=1", signedTestToken(t, "test-secret"))
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, w.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (body=%s)", len(items), w.Body.String())
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); id != episodeIDs[2] {
|
||||
t.Fatalf("Id = %q, want the partially watched episode %q (body=%s)", id, episodeIDs[2], w.Body.String())
|
||||
}
|
||||
if index, ok := items[0]["IndexNumber"].(float64); !ok || int(index) != 2 {
|
||||
t.Fatalf("IndexNumber = %v, want 2 (body=%s)", items[0]["IndexNumber"], w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// 没有历史时必须返回合法空信封,不能 404/500。
|
||||
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
|
||||
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||
@@ -242,3 +286,87 @@ func TestEmbyNextUpRejectsForeignUserID(t *testing.T) {
|
||||
t.Fatalf("items = %d, want 0", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
// YamBy 等客户端进入剧集详情会带 SeriesId 调 NextUp;必须只返回该剧的下一集,
|
||||
// 不能回落成全站「继续观看」第一条,否则详情页播放会串到别的片子。
|
||||
func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
|
||||
router, svc, userID := newEmbyDiscoveryEnv(t)
|
||||
libID := seedEmbyLibrary(t, svc, "tv")
|
||||
recent := time.Now().Add(-time.Minute)
|
||||
older := time.Now().Add(-2 * time.Hour)
|
||||
|
||||
seedSeries := func(seriesID, title string, watchedAt time.Time) (watchedID, nextID string) {
|
||||
t.Helper()
|
||||
for ep := 1; ep <= 3; ep++ {
|
||||
m := &model.Media{
|
||||
LibraryID: libID, SeriesID: seriesID, Title: title,
|
||||
SeasonNum: 1, EpisodeNum: ep,
|
||||
Path: "/media/tv/" + seriesID + "/S1E" + strconv.Itoa(ep) + ".mkv",
|
||||
}
|
||||
if err := svc.Repo.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
switch ep {
|
||||
case 1:
|
||||
watchedID = m.ID
|
||||
h := &model.PlaybackHistory{
|
||||
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
|
||||
}
|
||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case 2:
|
||||
nextID = m.ID
|
||||
}
|
||||
}
|
||||
return watchedID, nextID
|
||||
}
|
||||
|
||||
_, _ = seedSeries("series-hot", "热门剧", recent)
|
||||
_, wantNext := seedSeries("series-cold", "目标剧", older)
|
||||
|
||||
token := signedTestToken(t, "test-secret")
|
||||
global := embyGet(t, router, "/emby/Shows/NextUp?Limit=10", token)
|
||||
if global.Code != http.StatusOK {
|
||||
t.Fatalf("global status = %d body=%s", global.Code, global.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, global.Body.Bytes()); len(items) < 2 {
|
||||
t.Fatalf("global items = %d, want >= 2 (body=%s)", len(items), global.Body.String())
|
||||
}
|
||||
|
||||
scoped := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-cold&Limit=10", token)
|
||||
if scoped.Code != http.StatusOK {
|
||||
t.Fatalf("scoped status = %d body=%s", scoped.Code, scoped.Body.String())
|
||||
}
|
||||
items := decodeItemsEnvelope(t, scoped.Body.Bytes())
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("scoped items = %d, want 1 (body=%s)", len(items), scoped.Body.String())
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); id != wantNext {
|
||||
t.Fatalf("scoped Id = %q, want %q (body=%s)", id, wantNext, scoped.Body.String())
|
||||
}
|
||||
if seriesID, _ := items[0]["SeriesId"].(string); seriesID != "series-cold" {
|
||||
t.Fatalf("scoped SeriesId = %q, want series-cold", seriesID)
|
||||
}
|
||||
|
||||
empty := embyGet(t, router, "/emby/Shows/NextUp?SeriesId=series-never-watched", token)
|
||||
if empty.Code != http.StatusOK {
|
||||
t.Fatalf("empty status = %d body=%s", empty.Code, empty.Body.String())
|
||||
}
|
||||
if items := decodeItemsEnvelope(t, empty.Body.Bytes()); len(items) != 0 {
|
||||
t.Fatalf("never-watched items = %d, want 0 (body=%s)", len(items), empty.Body.String())
|
||||
}
|
||||
|
||||
pathScoped := embyGet(t, router, "/emby/Shows/series-cold/NextUp?Limit=10", token)
|
||||
if pathScoped.Code != http.StatusOK {
|
||||
t.Fatalf("path scoped status = %d body=%s", pathScoped.Code, pathScoped.Body.String())
|
||||
}
|
||||
pathItems := decodeItemsEnvelope(t, pathScoped.Body.Bytes())
|
||||
if len(pathItems) != 1 {
|
||||
t.Fatalf("path scoped items = %d, want 1 (body=%s)", len(pathItems), pathScoped.Body.String())
|
||||
}
|
||||
if id, _ := pathItems[0]["Id"].(string); id != wantNext {
|
||||
t.Fatalf("path scoped Id = %q, want %q", id, wantNext)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,10 @@ func embyResumeItemsHandler(svc *service.Container) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
uid := embyEffectiveUserID(c)
|
||||
limit, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "Limit", "limit"), "20"))
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, limit)
|
||||
startIndex, _ := strconv.Atoi(embyFirstNonEmptyString(firstQueryValue(c, "StartIndex", "startIndex", "startindex"), "0"))
|
||||
// ParentId / SeriesId 收窄到当前库或当前剧,避免详情页继续播放串到全站历史。
|
||||
parentID := firstQueryValue(c, "ParentId", "parentId", "parentid", "SeriesId", "seriesId", "seriesid")
|
||||
out, err := svc.Emby.ResumeItems(c.Request.Context(), uid, parentID, limit, startIndex)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
|
||||
@@ -198,6 +198,9 @@ func registerEmbyAuthenticatedItemRoutes(auth *gin.RouterGroup, svc *service.Con
|
||||
auth.GET("/Users/:userId/Shows/:id/Episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/Shows/NextUp", embyNextUpHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/NextUp", embyNextUpHandler(svc))
|
||||
// 部分客户端用路径形式 /Shows/{seriesId}/NextUp,而不是 query SeriesId。
|
||||
auth.GET("/Shows/:id/NextUp", embyShowNextUpHandler(svc))
|
||||
auth.GET("/Users/:userId/Shows/:id/NextUp", embyShowNextUpHandler(svc))
|
||||
auth.GET("/MediaSegments/:id", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/Users/:userId/Items/:id/MediaSegments", embyMediaSegmentsHandler(svc))
|
||||
|
||||
@@ -42,6 +42,8 @@ func registerLowercaseEmbyItemRoutes(auth *gin.RouterGroup, svc *service.Contain
|
||||
auth.GET("/users/:userId/shows/:id/episodes", embyShowEpisodesHandler(svc))
|
||||
auth.GET("/shows/nextup", embyNextUpHandler(svc))
|
||||
auth.GET("/users/:userId/shows/nextup", embyNextUpHandler(svc))
|
||||
auth.GET("/shows/:id/nextup", embyShowNextUpHandler(svc))
|
||||
auth.GET("/users/:userId/shows/:id/nextup", embyShowNextUpHandler(svc))
|
||||
auth.GET("/mediasegments/:id", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
|
||||
auth.GET("/users/:userId/items/:id/mediasegments", embyMediaSegmentsHandler(svc))
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// Emby 发现类接口:NextUp / Similar / Genres。
|
||||
@@ -21,7 +23,8 @@ const (
|
||||
)
|
||||
|
||||
// NextUp 返回「每部在看的剧的下一集」,即 Emby 客户端首页「接下来播放」的数据源。
|
||||
func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||
// seriesID 非空时只返回该剧的下一集(剧集详情页「继续播放」);空则返回全站列表。
|
||||
func (e *EmbyService) NextUp(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
|
||||
if limit <= 0 {
|
||||
limit = embyNextUpDefaultLimit
|
||||
}
|
||||
@@ -31,11 +34,16 @@ func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map
|
||||
if strings.TrimSpace(userID) == "" {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
seriesID = strings.TrimSpace(seriesID)
|
||||
discovery := e.discoveryService()
|
||||
if discovery == nil {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
|
||||
if seriesID != "" {
|
||||
return e.nextUpForSeries(ctx, userID, seriesID, limit)
|
||||
}
|
||||
|
||||
rows, err := discovery.NextUpCandidates(ctx, userID, limit, e.mediaVisibility(ctx, userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -52,6 +60,140 @@ func (e *EmbyService) NextUp(ctx context.Context, userID string, limit int) (map
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nextUpForSeries 只解析指定剧的下一集。远程挂载剧集按本机播放历史 + 远程
|
||||
// 分集列表计算,避免把其它本地剧的 NextUp 塞进详情页继续播放按钮。
|
||||
func (e *EmbyService) nextUpForSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
|
||||
if IsEmbyRemoteID(seriesID) {
|
||||
return e.nextUpForRemoteSeries(ctx, userID, seriesID, limit)
|
||||
}
|
||||
discovery := e.discoveryService()
|
||||
if discovery == nil {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
// 多取候选再按 SeriesId 精确过滤,避免「全站 TopN」把目标剧挤掉。
|
||||
scanLimit := embyNextUpMaxLimit
|
||||
if limit > scanLimit {
|
||||
scanLimit = limit
|
||||
}
|
||||
rows, err := discovery.NextUpCandidates(ctx, userID, scanLimit, e.mediaVisibility(ctx, userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, err := e.payloadsForMedia(ctx, rows, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filtered := make([]map[string]any, 0, 1)
|
||||
for _, item := range items {
|
||||
itemSeries, _ := item["SeriesId"].(string)
|
||||
if itemSeries == seriesID {
|
||||
filtered = append(filtered, item)
|
||||
if len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return map[string]any{
|
||||
"Items": filtered,
|
||||
"TotalRecordCount": int64(len(filtered)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nextUpForRemoteSeries 用 MeBox 本地播放历史在远程剧的分集里找「下一集」。
|
||||
// 不透传远程账号的 NextUp,避免多用户共用挂载账号时串进度。
|
||||
func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesID string, limit int) (map[string]any, error) {
|
||||
if e == nil || e.remote == nil || strings.TrimSpace(userID) == "" {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 1
|
||||
}
|
||||
mountID, remoteSeriesID, ok := DecodeEmbyRemoteID(seriesID)
|
||||
if !ok {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
mount, acct, err := e.remote.ResolveMount(ctx, mountID)
|
||||
if err != nil || mount == nil || acct == nil {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
if !EmbyMountLibraryAllowed(e.mediaVisibility(ctx, userID), mount) {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
|
||||
prefix := EmbyRemoteIDPrefix + mountID + "~"
|
||||
var hist []model.PlaybackHistory
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND position_ms > 0 AND media_id LIKE ?", userID, prefix+"%").
|
||||
Order("watched_at desc").
|
||||
Limit(nextUpHistoryScanLimit).
|
||||
Find(&hist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(hist) == 0 {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
|
||||
episodes, err := e.remote.RemoteEpisodes(ctx, mount, acct, remoteSeriesID)
|
||||
if err != nil || len(episodes) == 0 {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
epByID := make(map[string]*model.Media, len(episodes))
|
||||
for i := range episodes {
|
||||
epByID[episodes[i].ID] = &episodes[i]
|
||||
}
|
||||
|
||||
var current *model.Media
|
||||
currentCompleted := false
|
||||
for i := range hist {
|
||||
if m := epByID[hist[i].MediaID]; m != nil {
|
||||
current = m
|
||||
currentCompleted = hist[i].Completed
|
||||
break
|
||||
}
|
||||
}
|
||||
if current == nil {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
|
||||
completed := map[string]bool{}
|
||||
epIDs := make([]string, 0, len(episodes))
|
||||
for i := range episodes {
|
||||
epIDs = append(epIDs, episodes[i].ID)
|
||||
}
|
||||
var done []model.PlaybackHistory
|
||||
if err := e.repo.DB.WithContext(ctx).
|
||||
Where("user_id = ? AND completed = ? AND media_id IN ?", userID, true, epIDs).
|
||||
Find(&done).Error; err == nil {
|
||||
for _, h := range done {
|
||||
completed[h.MediaID] = true
|
||||
}
|
||||
}
|
||||
|
||||
next, ok := pickNextEpisode(episodes, current, currentCompleted, completed)
|
||||
if !ok {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
_, remoteEpID, ok := DecodeEmbyRemoteID(next.ID)
|
||||
if !ok {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
item, err := e.remote.RemoteItem(ctx, mount, acct, remoteEpID)
|
||||
if err != nil || item == nil {
|
||||
return emptyItemsEnvelope(0), nil
|
||||
}
|
||||
if err := e.mergeRemoteUserData(ctx, userID, item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := []map[string]any{item}
|
||||
if limit < len(items) {
|
||||
items = items[:limit]
|
||||
}
|
||||
return map[string]any{
|
||||
"Items": items,
|
||||
"TotalRecordCount": int64(len(items)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SimilarItems 返回与指定条目相似的本地媒体。
|
||||
//
|
||||
// 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在
|
||||
|
||||
@@ -299,8 +299,14 @@ func embyLatestSeriesRowLimit(limit int) int {
|
||||
}
|
||||
|
||||
// ResumeItems 列出有未完成播放进度的媒体。
|
||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID string, limit int) (map[string]any, error) {
|
||||
return e.resumableItems(ctx, ItemsParams{UserID: userID, Limit: limit})
|
||||
// parentID 非空时收窄到该库 / 该剧(含虚拟 msgo-series-* ID)。
|
||||
func (e *EmbyService) ResumeItems(ctx context.Context, userID, parentID string, limit, startIndex int) (map[string]any, error) {
|
||||
return e.resumableItems(ctx, ItemsParams{
|
||||
UserID: userID,
|
||||
ParentID: strings.TrimSpace(parentID),
|
||||
Limit: limit,
|
||||
StartIndex: startIndex,
|
||||
})
|
||||
}
|
||||
|
||||
// favoriteItems returns favourited media for Emby clients, including mounted
|
||||
@@ -434,6 +440,30 @@ func favoriteMatchesParent(ctx context.Context, e *EmbyService, parentID, mediaI
|
||||
return wantMountID != "" && gotMountID == wantMountID
|
||||
}
|
||||
|
||||
// resumeMatchesParent 判断续播条目是否属于 ParentId / SeriesId 作用域。
|
||||
// 本地剧集的 series_id 常为空,实际对外 ID 是 msgo-series-* 虚拟 ID,必须用
|
||||
// seriesIDForMedia 对齐,否则按剧收窄永远匹配不上。
|
||||
func resumeMatchesParent(ctx context.Context, e *EmbyService, parentID, libraryID, seriesID string, m *model.Media) bool {
|
||||
if parentID == "" {
|
||||
return true
|
||||
}
|
||||
if libraryID == parentID || seriesID == parentID {
|
||||
return true
|
||||
}
|
||||
if m != nil && e.seriesIDForMedia(ctx, m) == parentID {
|
||||
return true
|
||||
}
|
||||
if m != nil && e.seasonIDForMedia(ctx, m) == parentID {
|
||||
return true
|
||||
}
|
||||
for _, id := range e.mergedLibraryIDs(ctx, parentID) {
|
||||
if id == libraryID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// resumableItems 返回未完成播放进度的媒体(包含本地媒体与挂载的远程媒体),支持分页。
|
||||
func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[string]any, error) {
|
||||
if p.Limit <= 0 || p.Limit > 100 {
|
||||
@@ -500,7 +530,7 @@ func (e *EmbyService) resumableItems(ctx context.Context, p ItemsParams) (map[st
|
||||
localTotal, remoteTotal := 0, 0
|
||||
for _, h := range hist {
|
||||
if m, ok := byID[h.MediaID]; ok {
|
||||
if p.ParentID != "" && m.LibraryID != p.ParentID && m.SeriesID != p.ParentID {
|
||||
if p.ParentID != "" && !resumeMatchesParent(ctx, e, p.ParentID, m.LibraryID, m.SeriesID, m) {
|
||||
continue
|
||||
}
|
||||
localTotal++
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 回归:挂载的远程 Emby 剧集里,只看了几秒就退出的那一集必须仍是 NextUp 的结果。
|
||||
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
|
||||
func TestMountedRemoteNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
episode := func(id string, index int) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": id,
|
||||
"Name": "第" + strconv.Itoa(index) + "集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-100",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": index,
|
||||
"RunTimeTicks": 14400640000,
|
||||
}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/emby/Users/uid-1/Items/series-100":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
|
||||
case "/emby/Users/uid-1/Items/ep-2":
|
||||
_ = json.NewEncoder(w).Encode(episode("ep-2", 2))
|
||||
case "/emby/Users/uid-1/Items":
|
||||
q := r.URL.Query()
|
||||
if q.Get("IncludeItemTypes") != "Episode" || q.Get("ParentId") != "series-100" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 3,
|
||||
"Items": []map[string]any{
|
||||
episode("ep-1", 1),
|
||||
episode("ep-2", 2),
|
||||
episode("ep-3", 3),
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
|
||||
repos := repository.New(db)
|
||||
|
||||
cfg := &config.Config{}
|
||||
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "远程 Emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
user := &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
// 第 2 集只播了 3.5 秒就退出:有进度、未标记看完。
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
|
||||
PositionMs: 3582,
|
||||
DurationMs: 1440064,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: false,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create history: %v", err)
|
||||
}
|
||||
|
||||
envelope, err := svc.NextUp(t.Context(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("NextUp: %v", err)
|
||||
}
|
||||
items, _ := envelope["Items"].([]map[string]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
|
||||
}
|
||||
wantID := EncodeEmbyRemoteID(mount.ID, "ep-2")
|
||||
if id, _ := items[0]["Id"].(string); id != wantID {
|
||||
t.Fatalf("Id = %q, want %q (未看完的那一集不能被跳过)", id, wantID)
|
||||
}
|
||||
userData, _ := items[0]["UserData"].(map[string]any)
|
||||
if ticks, _ := userData["PlaybackPositionTicks"].(int64); ticks != 35820000 {
|
||||
t.Fatalf("PlaybackPositionTicks = %#v, want 35820000 (详情页要能续播到 3.5 秒)", userData["PlaybackPositionTicks"])
|
||||
}
|
||||
}
|
||||
|
||||
// 远程剧集已看完当前一集时,NextUp 仍要指向下一集。
|
||||
func TestMountedRemoteNextUpAfterCompletedEpisode(t *testing.T) {
|
||||
episode := func(id string, index int) map[string]any {
|
||||
return map[string]any{
|
||||
"Id": id,
|
||||
"Name": "第" + strconv.Itoa(index) + "集",
|
||||
"Type": "Episode",
|
||||
"SeriesId": "series-100",
|
||||
"ParentIndexNumber": 1,
|
||||
"IndexNumber": index,
|
||||
"RunTimeTicks": 14400640000,
|
||||
}
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch r.URL.Path {
|
||||
case "/emby/Users/uid-1/Items/series-100":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
|
||||
case "/emby/Users/uid-1/Items/ep-3":
|
||||
_ = json.NewEncoder(w).Encode(episode("ep-3", 3))
|
||||
case "/emby/Users/uid-1/Items":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"TotalRecordCount": 3,
|
||||
"Items": []map[string]any{
|
||||
episode("ep-1", 1),
|
||||
episode("ep-2", 2),
|
||||
episode("ep-3", 3),
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
|
||||
repos := repository.New(db)
|
||||
|
||||
cfg := &config.Config{}
|
||||
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
|
||||
|
||||
rawConfig, _ := json.Marshal(map[string]string{
|
||||
"url": server.URL,
|
||||
"api_key": "test-api-key",
|
||||
"remote_user_id": "uid-1",
|
||||
})
|
||||
acct := &model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"},
|
||||
Name: "远程 Emby",
|
||||
Provider: model.StrmProviderEmbyRemote,
|
||||
Config: string(rawConfig),
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||
t.Fatalf("create account: %v", err)
|
||||
}
|
||||
mount := &model.EmbyMount{
|
||||
Base: model.Base{ID: "mount-1"},
|
||||
AccountID: acct.ID,
|
||||
RemoteViewID: "view-1",
|
||||
RemoteViewName: "新番连载",
|
||||
CollectionType: "tvshows",
|
||||
Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||
t.Fatalf("create mount: %v", err)
|
||||
}
|
||||
user := &model.User{
|
||||
Base: model.Base{ID: "user-1"},
|
||||
Username: "viewer",
|
||||
PasswordHash: "x",
|
||||
Role: "user",
|
||||
Tier: "free",
|
||||
IsActive: true,
|
||||
}
|
||||
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
// 第 2 集已整集看完。
|
||||
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||
UserID: user.ID,
|
||||
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
|
||||
PositionMs: 1440064,
|
||||
DurationMs: 1440064,
|
||||
WatchedAt: time.Now(),
|
||||
Completed: true,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create history: %v", err)
|
||||
}
|
||||
|
||||
envelope, err := svc.NextUp(context.Background(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
|
||||
if err != nil {
|
||||
t.Fatalf("NextUp: %v", err)
|
||||
}
|
||||
items, _ := envelope["Items"].([]map[string]any)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
|
||||
}
|
||||
if id, _ := items[0]["Id"].(string); id != EncodeEmbyRemoteID(mount.ID, "ep-3") {
|
||||
t.Fatalf("Id = %q, want ep-3", id)
|
||||
}
|
||||
}
|
||||
@@ -212,10 +212,11 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
||||
byID[watchedRows[i].ID] = &watchedRows[i]
|
||||
}
|
||||
|
||||
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。
|
||||
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近看的那一集以及它是否看完。
|
||||
type seriesState struct {
|
||||
key string
|
||||
current *model.Media
|
||||
key string
|
||||
current *model.Media
|
||||
completed bool
|
||||
}
|
||||
states := make([]seriesState, 0, len(histories))
|
||||
seen := make(map[string]bool, len(histories))
|
||||
@@ -229,7 +230,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
states = append(states, seriesState{key: key, current: m})
|
||||
states = append(states, seriesState{key: key, current: m, completed: h.Completed})
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return nil, nil
|
||||
@@ -293,7 +294,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
||||
if len(out) >= limit {
|
||||
break
|
||||
}
|
||||
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed)
|
||||
next, ok := pickNextEpisode(bySeries[st.key], st.current, st.completed, completed)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -341,16 +342,27 @@ func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID st
|
||||
return out
|
||||
}
|
||||
|
||||
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。
|
||||
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。
|
||||
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) {
|
||||
// pickNextEpisode 选出这部剧「接下来该看的那一集」。
|
||||
//
|
||||
// anchor 是这部剧最近一次播放的那一集,anchorCompleted 表示那一集是否已看完:
|
||||
// - 没看完(只播了几秒就退出、或中途暂停)时,接下来该看的仍是这一集本身。
|
||||
// 否则详情页的「继续播放」会直接跳到下一集,用户刚看的那一集被静默跳过。
|
||||
// - 已看完时,才在候选集里取严格晚于它的、编号最小的一集;比较顺序为
|
||||
// (季, 集),因此跨季时自然落到下一季第一集。
|
||||
func pickNextEpisode(candidates []model.Media, anchor *model.Media, anchorCompleted bool, completed map[string]bool) (model.Media, bool) {
|
||||
if anchor == nil {
|
||||
return model.Media{}, false
|
||||
}
|
||||
if !anchorCompleted {
|
||||
return *anchor, true
|
||||
}
|
||||
var best model.Media
|
||||
found := false
|
||||
for _, candidate := range candidates {
|
||||
if candidate.ID == current.ID || completed[candidate.ID] {
|
||||
if candidate.ID == anchor.ID || completed[candidate.ID] {
|
||||
continue
|
||||
}
|
||||
if !episodeAfter(candidate, *current) {
|
||||
if !episodeAfter(candidate, *anchor) {
|
||||
continue
|
||||
}
|
||||
if !found || episodeBefore(candidate, best) {
|
||||
|
||||
@@ -13,6 +13,9 @@ import (
|
||||
)
|
||||
|
||||
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
|
||||
//
|
||||
// position_ms / duration_ms 只是占位值,NextUp 只看历史行的 completed 字段:
|
||||
// 未看完的那一集本身就是「接下来该看的一集」。
|
||||
func seedEpisode(
|
||||
t *testing.T,
|
||||
repos *repository.Container,
|
||||
@@ -78,7 +81,7 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, true) // 第 1 集已看完
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||
|
||||
@@ -89,6 +92,40 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 回归:只播了几秒就退出(未看完)时,「接下来该看的一集」仍是这一集本身。
|
||||
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
|
||||
func TestNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Minute)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, false) // 第 2 集只看了几秒
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||
if len(got) != 1 || got[0] != "S1E2" {
|
||||
t.Fatalf("next up = %v, want [S1E2] (未看完的那一集不能跳过)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 未看完的是这部剧的最后一集时也要返回它,不能因为「后面没有集了」而返回空。
|
||||
func TestNextUpKeepsPartiallyWatchedFinalEpisode(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Minute)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, nil, true)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, &watchedAt, false) // 最后一集未看完
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||
if len(got) != 1 || got[0] != "S1E2" {
|
||||
t.Fatalf("next up = %v, want [S1E2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 电影不进 NextUp:NextUp 的语义是「下一集」,电影由 Resume 接口负责。
|
||||
func TestNextUpSkipsMovies(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
@@ -104,6 +141,10 @@ func TestNextUpSkipsMovies(t *testing.T) {
|
||||
}
|
||||
|
||||
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
|
||||
// 同一部剧有多条未看完历史时只能出一条,且指向最近看过的那一集。
|
||||
//
|
||||
// 最近那一集(S1E2)本身还没看完,所以它就是「接下来该看的一集」;
|
||||
// S1E1 只是更早的中间进度,不能据此跳到 S1E3。
|
||||
func TestNextUpOneEntryPerSeries(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
@@ -116,8 +157,8 @@ func TestNextUpOneEntryPerSeries(t *testing.T) {
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||
if len(got) != 1 || got[0] != "S1E3" {
|
||||
t.Fatalf("next up = %v, want [S1E3]", got)
|
||||
if len(got) != 1 || got[0] != "S1E2" {
|
||||
t.Fatalf("next up = %v, want [S1E2]", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +168,7 @@ func TestNextUpCrossesSeason(t *testing.T) {
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 12, &watchedAt, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 12, &watchedAt, true) // 第 1 季最后一集已看完
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 1, nil, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, nil, false)
|
||||
|
||||
@@ -144,8 +185,8 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 1, &watchedAt, true) // 已看完,下一集是 S1E3
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
@@ -155,13 +196,13 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。
|
||||
// 追到最后一集且已看完时没有下一集,结果为空而不是重复返回最后一集。
|
||||
func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
|
||||
repos := newDiscoveryTestDB(t)
|
||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, false)
|
||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, &watchedAt, true)
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
|
||||
@@ -176,7 +217,7 @@ func TestNextUpRespectsVisibility(t *testing.T) {
|
||||
hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
|
||||
watchedAt := time.Now().Add(-time.Hour)
|
||||
|
||||
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, false)
|
||||
seedEpisode(t, repos, visibleLib, "series-1", "剧一", 1, 1, &watchedAt, true)
|
||||
seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
|
||||
|
||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||
|
||||
@@ -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