mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9c612df1b | |||
| e2cd32af53 | |||
| 28485ed429 | |||
| 34ccf14cda | |||
| 44fde6c5a0 |
@@ -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))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// MediaProbe 是一次 ffprobe 全量探测(容器 / 轨道 / 内嵌章节)的结果缓存。
|
||||
//
|
||||
// 它存在的理由:探测一次要 3~4 秒——远端直链更慢,因为要跨洋跑三次 HTTP
|
||||
// 事务。播放链路绝不能等它,所以第一次播放只起后台任务,结果落库后由后续请求
|
||||
// 与「跳过片头」的章节数据直接读库。
|
||||
//
|
||||
// Payload 刻意只保存裁剪后的字段:ffprobe 原始输出里的 format.filename 是解析
|
||||
// 后的播放直链(带网盘签名与 pickcode),原样落库等于把可直接下载的链接留在
|
||||
// 数据库里,所以只保留与技术信息有关的字段。
|
||||
type MediaProbe struct {
|
||||
Base
|
||||
MediaID string `gorm:"uniqueIndex;size:128;not null" json:"media_id"`
|
||||
// Signature 是「探的是哪个文件」的指纹(哈希):本地文件取路径 + 大小 +
|
||||
// 修改时间,STRM / 云盘取固化的播放目标。文件换了就说明缓存不再对应当前
|
||||
// 内容,需要重探。
|
||||
Signature string `gorm:"size:64" json:"signature,omitempty"`
|
||||
// Source 记录输入形态:local | strm。
|
||||
Source string `gorm:"size:16" json:"source,omitempty"`
|
||||
|
||||
Container string `gorm:"size:64" json:"container,omitempty"`
|
||||
DurationSec int `json:"duration_sec"`
|
||||
BitRate int64 `json:"bit_rate,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
VideoCodec string `gorm:"size:64" json:"video_codec,omitempty"`
|
||||
AudioCodec string `gorm:"size:64" json:"audio_codec,omitempty"`
|
||||
VideoStreams int `json:"video_streams"`
|
||||
AudioStreams int `json:"audio_streams"`
|
||||
SubtitleStreams int `json:"subtitle_streams"`
|
||||
ChapterCount int `json:"chapter_count"`
|
||||
|
||||
// Payload 是供详情页展示的裁剪后 JSON(容器 + 每路轨道 + 章节)。
|
||||
Payload string `gorm:"type:text" json:"payload,omitempty"`
|
||||
// ProbedAt 是最近一次探测的时刻。LastError 非空表示这次探测失败;失败只更新
|
||||
// 这两个字段,不会覆盖此前成功的 Payload 与已经落库的章节片段。
|
||||
ProbedAt time.Time `json:"probed_at"`
|
||||
LastError string `gorm:"size:512" json:"last_error,omitempty"`
|
||||
}
|
||||
@@ -40,6 +40,7 @@ func AllModels() []interface{} {
|
||||
&PlaybackHistory{},
|
||||
&MediaSegment{},
|
||||
&MediaSegmentFetch{},
|
||||
&MediaProbe{},
|
||||
&Favorite{},
|
||||
&Playlist{},
|
||||
&PlaylistItem{},
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
// MediaProbeRepository 持久化 ffprobe 全量探测的结果缓存。
|
||||
type MediaProbeRepository struct{ db *gorm.DB }
|
||||
|
||||
// Get 返回某媒体的探测缓存,未探测过时返回 (nil, nil)。
|
||||
func (r *MediaProbeRepository) Get(ctx context.Context, mediaID string) (*model.MediaProbe, error) {
|
||||
var row model.MediaProbe
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("media_id = ?", mediaID).
|
||||
First(&row).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// Upsert 写入一次成功的探测结果(含裁剪后的媒体信息)。
|
||||
func (r *MediaProbeRepository) Upsert(ctx context.Context, row *model.MediaProbe) error {
|
||||
onConflict := clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "media_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"signature", "source",
|
||||
"container", "duration_sec", "bit_rate",
|
||||
"width", "height", "video_codec", "audio_codec",
|
||||
"video_streams", "audio_streams", "subtitle_streams", "chapter_count",
|
||||
"payload", "probed_at", "last_error",
|
||||
"deleted_at",
|
||||
}),
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(onConflict).Create(row).Error
|
||||
}
|
||||
|
||||
// MarkFailure 记录一次失败的探测。它只更新「时间 + 错误信息」,刻意不碰
|
||||
// payload 与其它字段:一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
|
||||
func (r *MediaProbeRepository) MarkFailure(ctx context.Context, mediaID, message string, probedAt time.Time) error {
|
||||
row := model.MediaProbe{MediaID: mediaID, ProbedAt: probedAt, LastError: message}
|
||||
onConflict := clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "media_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]any{
|
||||
"probed_at": probedAt,
|
||||
"last_error": message,
|
||||
"deleted_at": nil,
|
||||
}),
|
||||
}
|
||||
return r.db.WithContext(ctx).Clauses(onConflict).Create(&row).Error
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
)
|
||||
|
||||
func TestMediaProbeUpsertReplacesAndGetReturnsLatest(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
|
||||
first := &model.MediaProbe{
|
||||
MediaID: "m-1", Signature: "sig-1", Source: "local",
|
||||
Container: "matroska,webm", DurationSec: 1451, ChapterCount: 2,
|
||||
Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
|
||||
}
|
||||
if err := repos.MediaProbe.Upsert(ctx, first); err != nil {
|
||||
t.Fatalf("upsert #1: %v", err)
|
||||
}
|
||||
second := &model.MediaProbe{
|
||||
MediaID: "m-1", Signature: "sig-2", Source: "strm",
|
||||
Container: "mp4", DurationSec: 900, ChapterCount: 0,
|
||||
Payload: `{"container":"mp4"}`, ProbedAt: time.Now(),
|
||||
}
|
||||
if err := repos.MediaProbe.Upsert(ctx, second); err != nil {
|
||||
t.Fatalf("upsert #2: %v", err)
|
||||
}
|
||||
|
||||
got, err := repos.MediaProbe.Get(ctx, "m-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatal("probe row missing")
|
||||
}
|
||||
if got.Container != "mp4" || got.DurationSec != 900 || got.Signature != "sig-2" || got.Payload != `{"container":"mp4"}` {
|
||||
t.Fatalf("probe = %#v, want the second upsert to win", got)
|
||||
}
|
||||
// 重复探测不能累积重复行:media_id 是唯一索引。
|
||||
var count int64
|
||||
if err := repos.DB.Model(&model.MediaProbe{}).Where("media_id = ?", "m-1").Count(&count).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("rows = %d, want 1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeGetReturnsNilWhenMissing(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
got, err := repos.MediaProbe.Get(t.Context(), "nope")
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if got != nil {
|
||||
t.Fatalf("probe = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeMarkFailureKeepsPreviousPayload(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := repos.MediaProbe.Upsert(ctx, &model.MediaProbe{
|
||||
MediaID: "m-1", Container: "matroska,webm", DurationSec: 1451,
|
||||
ChapterCount: 2, Payload: `{"container":"matroska,webm"}`, ProbedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.MediaProbe.MarkFailure(ctx, "m-1", "ffprobe full: exit status 1", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := repos.MediaProbe.Get(ctx, "m-1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.LastError != "ffprobe full: exit status 1" {
|
||||
t.Fatalf("probe = %#v, want the recorded failure", got)
|
||||
}
|
||||
// 一次失败的重探不该把上一次成功拿到的媒体信息抹掉。
|
||||
if got.Container != "matroska,webm" || got.DurationSec != 1451 || got.Payload == "" || got.ChapterCount != 2 {
|
||||
t.Fatalf("a failed re-probe wiped the previous summary: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeMarkFailureInsertsRowWhenAbsent(t *testing.T) {
|
||||
repos := newSegmentTestRepos(t)
|
||||
ctx := t.Context()
|
||||
|
||||
// 没有历史成功记录时,失败也必须落一行:否则冷却期没有时间戳,
|
||||
// 每次播放都会重跑一次注定失败的探测。
|
||||
if err := repos.MediaProbe.MarkFailure(ctx, "m-2", "boom", time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := repos.MediaProbe.Get(ctx, "m-2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.LastError != "boom" || got.ProbedAt.IsZero() {
|
||||
t.Fatalf("probe = %#v, want a failure row with a timestamp", got)
|
||||
}
|
||||
}
|
||||
@@ -199,6 +199,39 @@ func (r *MediaRepository) FindByID(ctx context.Context, id string) (*model.Media
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// ExistsSiblingWithTMDbID reports whether another row of the same show carries
|
||||
// the same tm_db_id as m.
|
||||
//
|
||||
// 它的用途是把「剧集级 id」和「单集自己的 id」区分开:一部剧的多集共用一个
|
||||
// 剧集级 id,而单集各自的 id 不会重复。调用方据此决定能否把 Media.TMDbID
|
||||
// 当作 Series.TMDbID 的替代品(见 MediaSegmentService.queryIDs)。
|
||||
//
|
||||
// 同一部剧的判定优先用 series_id;没有 series_id 的行(部分刮削路径不写它)
|
||||
// 退回到「同一个库 + 同一个标题」。查询失败按「不共用」处理:宁可不查,
|
||||
// 也不能拿一个可能是单集的 id 去查错片。
|
||||
func (r *MediaRepository) ExistsSiblingWithTMDbID(ctx context.Context, m *model.Media) bool {
|
||||
if r == nil || m == nil || m.TMDbID <= 0 || m.ID == "" {
|
||||
return false
|
||||
}
|
||||
query := r.db.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("tm_db_id = ? AND id <> ?", m.TMDbID, m.ID)
|
||||
if seriesID := strings.TrimSpace(m.SeriesID); seriesID != "" {
|
||||
query = query.Where("series_id = ?", seriesID)
|
||||
} else {
|
||||
libraryID := strings.TrimSpace(m.LibraryID)
|
||||
title := strings.TrimSpace(m.Title)
|
||||
if libraryID == "" || title == "" {
|
||||
return false
|
||||
}
|
||||
query = query.Where("library_id = ? AND title = ?", libraryID, title)
|
||||
}
|
||||
var count int64
|
||||
if err := query.Limit(1).Count(&count).Error; err != nil {
|
||||
return false
|
||||
}
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// ListByLibrary returns paginated media items for a library.
|
||||
func (r *MediaRepository) ListByLibrary(ctx context.Context, libraryID string, offset, limit int) ([]model.Media, int64, error) {
|
||||
return r.ListByLibraryFiltered(ctx, libraryID, offset, limit, MediaQueryFilter{IncludeNSFW: true})
|
||||
|
||||
@@ -23,6 +23,18 @@ func (r *MediaSegmentRepository) ListByMedia(ctx context.Context, mediaID string
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListByMediaSource returns the segments contributed by one source only, ordered
|
||||
// by start. Used to read the ffprobe-extracted chapters independently of the
|
||||
// community-database rows, so the player can pick between them.
|
||||
func (r *MediaSegmentRepository) ListByMediaSource(ctx context.Context, mediaID, source string) ([]model.MediaSegment, error) {
|
||||
rows := make([]model.MediaSegment, 0, 4)
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("media_id = ? AND source = ?", mediaID, source).
|
||||
Order("start_ms asc").
|
||||
Find(&rows).Error
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ReplaceForMedia swaps the segments contributed by one source in a single
|
||||
// transaction, so a provider refresh can never leave a half-updated set.
|
||||
//
|
||||
|
||||
@@ -16,6 +16,7 @@ type Container struct {
|
||||
Series *SeriesRepository
|
||||
History *HistoryRepository
|
||||
MediaSegment *MediaSegmentRepository
|
||||
MediaProbe *MediaProbeRepository
|
||||
Favorite *FavoriteRepository
|
||||
Playlist *PlaylistRepository
|
||||
Setting *SettingRepository
|
||||
@@ -47,6 +48,7 @@ func New(db *gorm.DB) *Container {
|
||||
Series: &SeriesRepository{db: db},
|
||||
History: &HistoryRepository{db: db},
|
||||
MediaSegment: &MediaSegmentRepository{db: db},
|
||||
MediaProbe: &MediaProbeRepository{db: db},
|
||||
Favorite: &FavoriteRepository{db: db},
|
||||
Playlist: &PlaylistRepository{db: db},
|
||||
Setting: &SettingRepository{db: db},
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ffprobeFullTimeout 是一次全量探测(含章节)的超时。远端直链实测 3~4 秒,
|
||||
// 留足余量的同时不能让一个坏源把工作协程永久占住。
|
||||
const ffprobeFullTimeout = 60 * time.Second
|
||||
|
||||
// ProbeInput 是喂给 ffprobe 的输入:本地文件给路径,STRM / 云盘给已解析的最终
|
||||
// 直链加绑定请求头。解析由调用方负责(复用播放链路的换链逻辑),否则会踩到
|
||||
// 115 CDN 的防盗链 403。
|
||||
type ProbeInput struct {
|
||||
Source string
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
// ProbeStream 是一路轨道的关键字段,供详情页展示。
|
||||
type ProbeStream struct {
|
||||
Index int `json:"index"`
|
||||
Type string `json:"type"`
|
||||
Codec string `json:"codec,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Language string `json:"language,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
PixFmt string `json:"pix_fmt,omitempty"`
|
||||
FrameRate string `json:"frame_rate,omitempty"`
|
||||
Channels int `json:"channels,omitempty"`
|
||||
ChannelLayout string `json:"channel_layout,omitempty"`
|
||||
SampleRate int `json:"sample_rate,omitempty"`
|
||||
BitRate int64 `json:"bit_rate,omitempty"`
|
||||
Default bool `json:"default,omitempty"`
|
||||
Forced bool `json:"forced,omitempty"`
|
||||
}
|
||||
|
||||
// ProbeChapter 是一个内嵌章节区间。
|
||||
type ProbeChapter struct {
|
||||
Index int `json:"index"`
|
||||
StartMs int64 `json:"start_ms"`
|
||||
EndMs int64 `json:"end_ms"`
|
||||
Title string `json:"title,omitempty"`
|
||||
}
|
||||
|
||||
// FullProbeResult 是一次全量探测的裁剪结果。
|
||||
type FullProbeResult struct {
|
||||
Container string
|
||||
DurationSec int
|
||||
BitRate int64
|
||||
Streams []ProbeStream
|
||||
Chapters []ProbeChapter
|
||||
}
|
||||
|
||||
// mediaProbePayload 是落库的媒体信息结构。它只包含白名单字段——刻意不接受
|
||||
// ffprobe 的原始 JSON,因为其中的 format.filename 是播放直链(含签名与
|
||||
// pickcode),原样保存会外泄。
|
||||
type mediaProbePayload struct {
|
||||
Container string `json:"container,omitempty"`
|
||||
DurationSec int `json:"duration_sec"`
|
||||
BitRate int64 `json:"bit_rate,omitempty"`
|
||||
Streams []ProbeStream `json:"streams"`
|
||||
Chapters []ProbeChapter `json:"chapters,omitempty"`
|
||||
}
|
||||
|
||||
// StreamsOfType 返回指定类型的轨道,供详情页分组展示。
|
||||
func (r *FullProbeResult) StreamsOfType(kind string) []ProbeStream {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
out := make([]ProbeStream, 0, len(r.Streams))
|
||||
for _, s := range r.Streams {
|
||||
if s.Type == kind {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PayloadJSON 序列化落库用的媒体信息。
|
||||
func (r *FullProbeResult) PayloadJSON() (string, error) {
|
||||
if r == nil {
|
||||
return "", nil
|
||||
}
|
||||
streams := r.Streams
|
||||
if streams == nil {
|
||||
streams = []ProbeStream{}
|
||||
}
|
||||
body, err := json.Marshal(mediaProbePayload{
|
||||
Container: r.Container,
|
||||
DurationSec: r.DurationSec,
|
||||
BitRate: r.BitRate,
|
||||
Streams: streams,
|
||||
Chapters: r.Chapters,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// ProbeFull 跑一次全量探测:容器信息 + 全部轨道 + 内嵌章节。
|
||||
//
|
||||
// 与 Probe 的区别:Probe 只取扫描需要的几个字段、对着媒体行的本地路径跑;
|
||||
// ProbeFull 接受调用方解析好的输入(远端直链 + 绑定请求头),并额外抓章节。
|
||||
func (f *FFprobeService) ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error) {
|
||||
if f == nil || f.cfg == nil {
|
||||
return nil, errors.New("ffprobe service nil")
|
||||
}
|
||||
source := strings.TrimSpace(input.Source)
|
||||
if source == "" {
|
||||
return nil, errors.New("empty probe source")
|
||||
}
|
||||
token, err := f.acquire(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.release(token)
|
||||
|
||||
bin, err := resolveLocalExecutable(f.cfg.App.FFprobePath, "ffprobe")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe unavailable: %w", err)
|
||||
}
|
||||
probeCtx, cancel := context.WithTimeout(ctx, ffprobeFullTimeout)
|
||||
defer cancel()
|
||||
|
||||
args := []string{"-v", "error"}
|
||||
if headerText := ffmpegHeaderText(input.Headers); headerText != "" {
|
||||
args = append(args, "-headers", headerText)
|
||||
}
|
||||
args = append(args,
|
||||
"-print_format", "json",
|
||||
"-show_format",
|
||||
"-show_streams",
|
||||
"-show_chapters",
|
||||
source,
|
||||
)
|
||||
out, err := exec.CommandContext(probeCtx, bin, args...).Output() // #nosec G204 -- bin is resolved by resolveLocalExecutable before execution.
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ffprobe full: %w", err)
|
||||
}
|
||||
return parseFullProbeJSON(out)
|
||||
}
|
||||
|
||||
// probeNumber 兼容 ffprobe 把数值输出成字符串或裸数字两种形态
|
||||
// (duration 是字符串,chapter 的 start_time 也可能是数字)。解析不出来就保持
|
||||
// 零值:这些字段都只是展示用,不该因为一个格式差异让整次探测失败。
|
||||
type probeNumber float64
|
||||
|
||||
func (p *probeNumber) UnmarshalJSON(data []byte) error {
|
||||
text := strings.TrimSpace(strings.Trim(string(data), `"`))
|
||||
if text == "" || text == "null" {
|
||||
return nil
|
||||
}
|
||||
value, err := strconv.ParseFloat(text, 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
*p = probeNumber(value)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p probeNumber) float() float64 { return float64(p) }
|
||||
func (p probeNumber) int() int { return int(float64(p)) }
|
||||
func (p probeNumber) int64() int64 { return int64(float64(p)) }
|
||||
|
||||
// rawFullProbe 镜像 ffprobe -show_format -show_streams -show_chapters 的输出。
|
||||
// 只声明用得到的字段;format.filename 刻意不声明,避免它进入任何落库路径。
|
||||
type rawFullProbe struct {
|
||||
Format struct {
|
||||
FormatName string `json:"format_name"`
|
||||
Duration probeNumber `json:"duration"`
|
||||
BitRate probeNumber `json:"bit_rate"`
|
||||
} `json:"format"`
|
||||
Streams []struct {
|
||||
Index int `json:"index"`
|
||||
CodecType string `json:"codec_type"`
|
||||
CodecName string `json:"codec_name"`
|
||||
Profile string `json:"profile"`
|
||||
Width int `json:"width"`
|
||||
Height int `json:"height"`
|
||||
PixFmt string `json:"pix_fmt"`
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
Channels int `json:"channels"`
|
||||
ChannelLayout string `json:"channel_layout"`
|
||||
SampleRate probeNumber `json:"sample_rate"`
|
||||
BitRate probeNumber `json:"bit_rate"`
|
||||
Tags struct {
|
||||
Language string `json:"language"`
|
||||
Title string `json:"title"`
|
||||
} `json:"tags"`
|
||||
Disposition struct {
|
||||
Default int `json:"default"`
|
||||
Forced int `json:"forced"`
|
||||
} `json:"disposition"`
|
||||
} `json:"streams"`
|
||||
Chapters []struct {
|
||||
StartTime probeNumber `json:"start_time"`
|
||||
EndTime probeNumber `json:"end_time"`
|
||||
Tags struct {
|
||||
Title string `json:"title"`
|
||||
} `json:"tags"`
|
||||
} `json:"chapters"`
|
||||
}
|
||||
|
||||
func parseFullProbeJSON(data []byte) (*FullProbeResult, error) {
|
||||
var raw rawFullProbe
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parse ffprobe json: %w", err)
|
||||
}
|
||||
result := &FullProbeResult{
|
||||
Container: strings.TrimSpace(raw.Format.FormatName),
|
||||
DurationSec: raw.Format.Duration.int(),
|
||||
BitRate: raw.Format.BitRate.int64(),
|
||||
Streams: make([]ProbeStream, 0, len(raw.Streams)),
|
||||
Chapters: make([]ProbeChapter, 0, len(raw.Chapters)),
|
||||
}
|
||||
for _, s := range raw.Streams {
|
||||
stream := ProbeStream{
|
||||
Index: s.Index,
|
||||
Type: s.CodecType,
|
||||
Codec: s.CodecName,
|
||||
Profile: s.Profile,
|
||||
Language: strings.TrimSpace(s.Tags.Language),
|
||||
Title: strings.TrimSpace(s.Tags.Title),
|
||||
Width: s.Width,
|
||||
Height: s.Height,
|
||||
PixFmt: s.PixFmt,
|
||||
FrameRate: normalizeFrameRate(s.AvgFrameRate),
|
||||
Channels: s.Channels,
|
||||
ChannelLayout: s.ChannelLayout,
|
||||
SampleRate: s.SampleRate.int(),
|
||||
BitRate: s.BitRate.int64(),
|
||||
Default: s.Disposition.Default != 0,
|
||||
Forced: s.Disposition.Forced != 0,
|
||||
}
|
||||
result.Streams = append(result.Streams, stream)
|
||||
}
|
||||
for index, chapter := range raw.Chapters {
|
||||
result.Chapters = append(result.Chapters, ProbeChapter{
|
||||
Index: index,
|
||||
StartMs: secondsToMillis(chapter.StartTime.float()),
|
||||
EndMs: secondsToMillis(chapter.EndTime.float()),
|
||||
Title: strings.TrimSpace(chapter.Tags.Title),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// secondsToMillis 把 ffprobe 的秒(浮点)转成毫秒整数。
|
||||
func secondsToMillis(seconds float64) int64 {
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return int64(seconds*1000 + 0.5)
|
||||
}
|
||||
|
||||
// normalizeFrameRate 把 "24000/1001" 这类分数帧率换算成可读形式;"0/0"
|
||||
// (未知)返回空串。
|
||||
func normalizeFrameRate(raw string) string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.SplitN(raw, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return raw
|
||||
}
|
||||
num, errNum := strconv.ParseFloat(parts[0], 64)
|
||||
den, errDen := strconv.ParseFloat(parts[1], 64)
|
||||
if errNum != nil || errDen != nil || den == 0 || num <= 0 {
|
||||
return ""
|
||||
}
|
||||
return strconv.FormatFloat(num/den, 'f', 3, 64)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const fullProbeFixture = `{
|
||||
"format": {
|
||||
"format_name": "matroska,webm",
|
||||
"duration": "1451.024000",
|
||||
"bit_rate": "8000000",
|
||||
"filename": "https://cdn.example.com/secret/movie.mkv?d=vip-abc-pickcode&token=xyz"
|
||||
},
|
||||
"streams": [
|
||||
{"index": 0, "codec_type": "video", "codec_name": "hevc", "profile": "Main 10",
|
||||
"width": 3840, "height": 2160, "pix_fmt": "yuv420p10le", "avg_frame_rate": "24000/1001",
|
||||
"bit_rate": "7800000", "disposition": {"default": 1}},
|
||||
{"index": 1, "codec_type": "audio", "codec_name": "eac3", "channels": 6,
|
||||
"channel_layout": "5.1(side)", "sample_rate": "48000",
|
||||
"tags": {"language": "eng", "title": "Surround"}, "disposition": {"default": 1}},
|
||||
{"index": 2, "codec_type": "subtitle", "codec_name": "ass",
|
||||
"tags": {"language": "chi"}, "disposition": {"forced": 1}}
|
||||
],
|
||||
"chapters": [
|
||||
{"start_time": "0.000000", "end_time": "95.000000", "tags": {"title": "Chapter 01"}},
|
||||
{"start_time": "228.664000", "end_time": "246.143000", "tags": {"title": "Opening"}}
|
||||
]
|
||||
}`
|
||||
|
||||
func TestParseFullProbeJSONExtractsStreamsAndChapters(t *testing.T) {
|
||||
got, err := parseFullProbeJSON([]byte(fullProbeFixture))
|
||||
if err != nil {
|
||||
t.Fatalf("parseFullProbeJSON: %v", err)
|
||||
}
|
||||
if got.Container != "matroska,webm" || got.DurationSec != 1451 || got.BitRate != 8_000_000 {
|
||||
t.Fatalf("container/duration/bitrate = %q/%d/%d", got.Container, got.DurationSec, got.BitRate)
|
||||
}
|
||||
if len(got.Streams) != 3 {
|
||||
t.Fatalf("streams = %#v, want 3", got.Streams)
|
||||
}
|
||||
video := got.Streams[0]
|
||||
if video.Type != "video" || video.Codec != "hevc" || video.Width != 3840 || video.Height != 2160 {
|
||||
t.Fatalf("video stream = %#v", video)
|
||||
}
|
||||
if video.FrameRate != "23.976" {
|
||||
t.Fatalf("frame rate = %q, want 23.976 (converted from 24000/1001)", video.FrameRate)
|
||||
}
|
||||
if !video.Default {
|
||||
t.Fatal("video stream should be flagged default")
|
||||
}
|
||||
audio := got.Streams[1]
|
||||
if audio.Codec != "eac3" || audio.Language != "eng" || audio.Title != "Surround" || audio.Channels != 6 || audio.SampleRate != 48000 {
|
||||
t.Fatalf("audio stream = %#v", audio)
|
||||
}
|
||||
sub := got.Streams[2]
|
||||
if sub.Type != "subtitle" || sub.Language != "chi" || !sub.Forced {
|
||||
t.Fatalf("subtitle stream = %#v", sub)
|
||||
}
|
||||
|
||||
if len(got.Chapters) != 2 {
|
||||
t.Fatalf("chapters = %#v, want 2", got.Chapters)
|
||||
}
|
||||
if got.Chapters[1].StartMs != 228_664 || got.Chapters[1].EndMs != 246_143 || got.Chapters[1].Title != "Opening" {
|
||||
t.Fatalf("chapter[1] = %#v", got.Chapters[1])
|
||||
}
|
||||
|
||||
if videoCount := len(got.StreamsOfType("video")); videoCount != 1 {
|
||||
t.Fatalf("StreamsOfType(video) = %d, want 1", videoCount)
|
||||
}
|
||||
if audioCount := len(got.StreamsOfType("audio")); audioCount != 1 {
|
||||
t.Fatalf("StreamsOfType(audio) = %d, want 1", audioCount)
|
||||
}
|
||||
}
|
||||
|
||||
// 落库的 payload 绝不能带 ffprobe 的 format.filename:那是解析后的播放直链,
|
||||
// 里面是网盘签名和 pickcode,存进数据库等于把可直接下载的链接留下来。
|
||||
func TestFullProbePayloadNeverLeaksSourceURL(t *testing.T) {
|
||||
got, err := parseFullProbeJSON([]byte(fullProbeFixture))
|
||||
if err != nil {
|
||||
t.Fatalf("parseFullProbeJSON: %v", err)
|
||||
}
|
||||
payload, err := got.PayloadJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("PayloadJSON: %v", err)
|
||||
}
|
||||
for _, needle := range []string{"filename", "cdn.example.com", "pickcode", "token=", "http"} {
|
||||
if strings.Contains(payload, needle) {
|
||||
t.Fatalf("payload 泄漏了 %q:\n%s", needle, payload)
|
||||
}
|
||||
}
|
||||
// 技术信息本身必须保留。
|
||||
for _, needle := range []string{"matroska,webm", "hevc", "3840", "Opening"} {
|
||||
if !strings.Contains(payload, needle) {
|
||||
t.Fatalf("payload 缺少 %q:\n%s", needle, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ffprobe 对 duration / start_time 有时输出字符串、有时输出裸数字,两种都要能读。
|
||||
func TestParseFullProbeJSONAcceptsNumericAndStringTimes(t *testing.T) {
|
||||
got, err := parseFullProbeJSON([]byte(`{
|
||||
"format": {"format_name": "mp4", "duration": 125.5},
|
||||
"streams": [],
|
||||
"chapters": [{"start_time": 12.5, "end_time": 20}]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseFullProbeJSON: %v", err)
|
||||
}
|
||||
if got.DurationSec != 125 {
|
||||
t.Fatalf("duration = %d, want 125", got.DurationSec)
|
||||
}
|
||||
if len(got.Chapters) != 1 || got.Chapters[0].StartMs != 12_500 || got.Chapters[0].EndMs != 20_000 {
|
||||
t.Fatalf("chapters = %#v", got.Chapters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFullProbeJSONToleratesMissingSections(t *testing.T) {
|
||||
got, err := parseFullProbeJSON([]byte(`{}`))
|
||||
if err != nil {
|
||||
t.Fatalf("parseFullProbeJSON: %v", err)
|
||||
}
|
||||
if got.DurationSec != 0 || len(got.Streams) != 0 || len(got.Chapters) != 0 {
|
||||
t.Fatalf("result = %#v, want empty", got)
|
||||
}
|
||||
payload, err := got.PayloadJSON()
|
||||
if err != nil {
|
||||
t.Fatalf("PayloadJSON: %v", err)
|
||||
}
|
||||
// 空轨道必须序列化成 [],不能是 null——详情页前端按数组消费。
|
||||
if !strings.Contains(payload, `"streams":[]`) {
|
||||
t.Fatalf("payload = %s, want an empty streams array", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeFrameRate(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"24000/1001": "23.976",
|
||||
"25/1": "25.000",
|
||||
"0/0": "",
|
||||
"": "",
|
||||
"25": "25",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := normalizeFrameRate(input); got != want {
|
||||
t.Errorf("normalizeFrameRate(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+119
-12
@@ -29,6 +29,15 @@ const (
|
||||
|
||||
introDBTimeout = 8 * time.Second
|
||||
introDBMaxBodySize = 1 << 20
|
||||
// introDBMaxAttempts 是一次 Fetch 允许的请求次数(原请求 + 1 次重试)。
|
||||
// 实测:短时间连发 45 个请求有 15 个被返回 429,加 1.2 秒间隔重试后
|
||||
// 其中 10 个成功,所以限流是真实存在的、值得一次重试。
|
||||
introDBMaxAttempts = 2
|
||||
// introDBRetryDelay 是服务端没给 Retry-After 时的默认重试间隔。
|
||||
introDBRetryDelay = time.Second
|
||||
// introDBMaxRetryDelay 限制服务端要求的等待时间:一次播放不值得为它
|
||||
// 挂住几十秒,等待超过这个值就按这个值等(然后可能再次被限流)。
|
||||
introDBMaxRetryDelay = 3 * time.Second
|
||||
)
|
||||
|
||||
// IntroDBSpan is one resolved skip range, still in provider terms.
|
||||
@@ -42,18 +51,20 @@ type IntroDBSpan struct {
|
||||
|
||||
// IntroDBService queries TheIntroDB for one media item.
|
||||
type IntroDBService struct {
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
baseURL string
|
||||
log *zap.Logger
|
||||
client *http.Client
|
||||
baseURL string
|
||||
retryDelay time.Duration
|
||||
}
|
||||
|
||||
// NewIntroDBService is the constructor. The client honours environment and OS
|
||||
// proxy settings so it behaves like the other third-party API clients.
|
||||
func NewIntroDBService(log *zap.Logger) *IntroDBService {
|
||||
return &IntroDBService{
|
||||
log: log,
|
||||
client: NewExternalHTTPClient(introDBTimeout),
|
||||
baseURL: IntroDBBaseURL,
|
||||
log: log,
|
||||
client: NewExternalHTTPClient(introDBTimeout),
|
||||
baseURL: IntroDBBaseURL,
|
||||
retryDelay: introDBRetryDelay,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +76,15 @@ func (s *IntroDBService) SetBaseURL(base string) *IntroDBService {
|
||||
return s
|
||||
}
|
||||
|
||||
// SetRetryDelay overrides the wait between attempts. Tests set it to 0 so a
|
||||
// retry does not really sleep.
|
||||
func (s *IntroDBService) SetRetryDelay(delay time.Duration) *IntroDBService {
|
||||
if s != nil {
|
||||
s.retryDelay = delay
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// introDBRange mirrors one entry of a segment array. start_ms/end_ms are
|
||||
// pointers because the API distinguishes null (= open-ended) from 0.
|
||||
type introDBRange struct {
|
||||
@@ -86,6 +106,10 @@ type introDBResponse struct {
|
||||
// caller records it as a negative cache entry.
|
||||
//
|
||||
// season/episode are required for TV; pass 0/0 for movies.
|
||||
//
|
||||
// 429/503 会重试一次(社区库在短时间连发下确实会限流)。重试前会先确认调用方
|
||||
// 的 deadline 还够用;预算不够就直接返回错误,让调用方保留自己的缓存,
|
||||
// 把「拿不到片段」维持在「少一个跳过按钮」的量级。
|
||||
func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int) ([]IntroDBSpan, error) {
|
||||
if s == nil || s.client == nil {
|
||||
return nil, errors.New("introdb service nil")
|
||||
@@ -94,28 +118,111 @@ func (s *IntroDBService) Fetch(ctx context.Context, tmdbID, season, episode int)
|
||||
return nil, nil
|
||||
}
|
||||
endpoint := s.mediaURL(tmdbID, season, episode)
|
||||
for attempt := 1; ; attempt++ {
|
||||
result := s.fetchOnce(ctx, endpoint)
|
||||
if result.err == nil {
|
||||
return result.spans, nil
|
||||
}
|
||||
if !result.retryable || attempt >= introDBMaxAttempts {
|
||||
return nil, result.err
|
||||
}
|
||||
if !waitForIntroDBRetry(ctx, s.retryWait(result.retryAfter)) {
|
||||
return nil, result.err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// introDBFetchAttempt 是一次请求的结果:数据或错误,外加「值不值得重试」。
|
||||
type introDBFetchAttempt struct {
|
||||
spans []IntroDBSpan
|
||||
err error
|
||||
retryable bool
|
||||
retryAfter time.Duration
|
||||
}
|
||||
|
||||
func (s *IntroDBService) fetchOnce(ctx context.Context, endpoint string) introDBFetchAttempt {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return introDBFetchAttempt{err: err}
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
resp, err := s.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return introDBFetchAttempt{err: err}
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return nil, nil
|
||||
// 「查到但社区库里没有」不是错误,调用方据此写负缓存。
|
||||
return introDBFetchAttempt{}
|
||||
case resp.StatusCode < 200 || resp.StatusCode >= 300:
|
||||
return nil, fmt.Errorf("introdb: unexpected status %d", resp.StatusCode)
|
||||
return introDBFetchAttempt{
|
||||
err: fmt.Errorf("introdb: unexpected status %d", resp.StatusCode),
|
||||
retryable: introDBRetryableStatus(resp.StatusCode),
|
||||
retryAfter: parseIntroDBRetryAfter(resp.Header.Get("Retry-After")),
|
||||
}
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, introDBMaxBodySize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return introDBFetchAttempt{err: err}
|
||||
}
|
||||
spans, err := parseIntroDBResponse(body)
|
||||
if err != nil {
|
||||
return introDBFetchAttempt{err: err}
|
||||
}
|
||||
return introDBFetchAttempt{spans: spans}
|
||||
}
|
||||
|
||||
// introDBRetryableStatus 只认明确的「稍后再来」状态。500 之类的服务端故障
|
||||
// 重试也不会变好,却会白占调用方的等待预算。
|
||||
func introDBRetryableStatus(status int) bool {
|
||||
switch status {
|
||||
case http.StatusTooManyRequests, http.StatusServiceUnavailable:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// parseIntroDBRetryAfter 解析 Retry-After 的秒数形式;HTTP-date 形式在限流
|
||||
// 场景很少见,解析不出来就退回默认间隔。
|
||||
func parseIntroDBRetryAfter(value string) time.Duration {
|
||||
seconds, err := strconv.Atoi(strings.TrimSpace(value))
|
||||
if err != nil || seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func (s *IntroDBService) retryWait(retryAfter time.Duration) time.Duration {
|
||||
wait := retryAfter
|
||||
if wait <= 0 {
|
||||
wait = s.retryDelay
|
||||
}
|
||||
if wait > introDBMaxRetryDelay {
|
||||
wait = introDBMaxRetryDelay
|
||||
}
|
||||
return wait
|
||||
}
|
||||
|
||||
// waitForIntroDBRetry 睡到重试时刻,或调用方的 ctx 先结束。返回 false 表示
|
||||
// 预算已经用完,调用方不该再等。
|
||||
func waitForIntroDBRetry(ctx context.Context, wait time.Duration) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
if wait <= 0 {
|
||||
return true
|
||||
}
|
||||
timer := time.NewTimer(wait)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-timer.C:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
return parseIntroDBResponse(body)
|
||||
}
|
||||
|
||||
func (s *IntroDBService) mediaURL(tmdbID, season, episode int) string {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
@@ -160,3 +163,142 @@ func TestIntroDBFetchParsesBody(t *testing.T) {
|
||||
t.Fatalf("spans = %#v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// 实测:从生产机连发 45 个请求有 15 个被返回 429,加间隔重试后其中 10 个成功。
|
||||
// 所以限流值得一次重试,否则那部分播放会静默少掉「跳过片头」按钮。
|
||||
func TestIntroDBFetchRetriesRateLimit(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
if atomic.AddInt32(&calls, 1) == 1 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(introDBTVPayload))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)
|
||||
spans, err := svc.Fetch(t.Context(), 1396, 1, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("fetch: %v", err)
|
||||
}
|
||||
if len(spans) != 2 {
|
||||
t.Fatalf("spans = %#v, want the retried response", spans)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (the original plus one retry)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchGivesUpAfterRetryBudget(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)
|
||||
if _, err := svc.Fetch(t.Context(), 1396, 1, 1); err == nil {
|
||||
t.Fatal("a persistent 429 must surface as an error so the caller keeps its cache")
|
||||
}
|
||||
// 只重试一次:限流通常不是靠密集重试解决的,而调用方的等待预算有限。
|
||||
if got := atomic.LoadInt32(&calls); got != 2 {
|
||||
t.Fatalf("calls = %d, want 2 (one retry, then give up)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBFetchDoesNotRetryNotFound(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(0)
|
||||
spans, err := svc.Fetch(t.Context(), 424242, 0, 0)
|
||||
if err != nil || len(spans) != 0 {
|
||||
t.Fatalf("spans = %#v err = %v, want a cached miss", spans, err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("calls = %d, want 1: 404 means \"no data\", it is not worth retrying", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 调用方预算不够时不能为了重试干等:Emby 只给 5 秒,等下去会把
|
||||
// 「少一个跳过按钮」升级成「请求超时」。
|
||||
func TestIntroDBFetchSkipsRetryWhenCallerBudgetIsSpent(t *testing.T) {
|
||||
var calls int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
atomic.AddInt32(&calls, 1)
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
svc := NewIntroDBService(zap.NewNop()).SetBaseURL(server.URL).SetRetryDelay(time.Minute)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
started := time.Now()
|
||||
if _, err := svc.Fetch(ctx, 1396, 1, 1); err == nil {
|
||||
t.Fatal("want an error when the budget is spent")
|
||||
}
|
||||
if elapsed := time.Since(started); elapsed > 2*time.Second {
|
||||
t.Fatalf("Fetch waited %s, want it to give up promptly", elapsed)
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("calls = %d, want 1 without a retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBRetryWaitPrefersRetryAfterHeader(t *testing.T) {
|
||||
svc := NewIntroDBService(zap.NewNop())
|
||||
if got := svc.retryWait(2 * time.Second); got != 2*time.Second {
|
||||
t.Fatalf("retryWait(2s) = %s, want the server's value", got)
|
||||
}
|
||||
if got := svc.retryWait(0); got != introDBRetryDelay {
|
||||
t.Fatalf("retryWait(0) = %s, want the default %s", got, introDBRetryDelay)
|
||||
}
|
||||
// 服务端可以要求等很久,但一次播放不值得为它挂住几十秒。
|
||||
if got := svc.retryWait(10 * time.Minute); got != introDBMaxRetryDelay {
|
||||
t.Fatalf("retryWait(10m) = %s, want it capped at %s", got, introDBMaxRetryDelay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseIntroDBRetryAfter(t *testing.T) {
|
||||
cases := []struct {
|
||||
value string
|
||||
want time.Duration
|
||||
}{
|
||||
{"2", 2 * time.Second},
|
||||
{" 3 ", 3 * time.Second},
|
||||
{"", 0},
|
||||
{"abc", 0},
|
||||
{"-5", 0},
|
||||
{"0", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := parseIntroDBRetryAfter(tc.value); got != tc.want {
|
||||
t.Fatalf("parseIntroDBRetryAfter(%q) = %s, want %s", tc.value, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntroDBRetryableStatus(t *testing.T) {
|
||||
retryable := []int{http.StatusTooManyRequests, http.StatusServiceUnavailable}
|
||||
for _, status := range retryable {
|
||||
if !introDBRetryableStatus(status) {
|
||||
t.Fatalf("status %d should be retryable", status)
|
||||
}
|
||||
}
|
||||
// 404 由 fetchOnce 单独处理;500 之类的服务端故障重试也不会变好,
|
||||
// 却会白占调用方的等待预算。
|
||||
notRetryable := []int{http.StatusNotFound, http.StatusInternalServerError, http.StatusBadRequest}
|
||||
for _, status := range notRetryable {
|
||||
if introDBRetryableStatus(status) {
|
||||
t.Fatalf("status %d should not be retryable", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/helper"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// 探测结果的缓存与预算策略。
|
||||
const (
|
||||
// mediaProbeTimeout 是一次后台探测的总预算(含把 strm 目标解析成直链)。
|
||||
mediaProbeTimeout = 90 * time.Second
|
||||
// mediaProbeFailureRetry 是探测失败后允许重新探测的间隔。失败的结果也会落库,
|
||||
// 否则每次播放都会为一个坏源重跑一次。
|
||||
mediaProbeFailureRetry = 6 * time.Hour
|
||||
// mediaProbeErrorLimit 限制落库的错误信息长度(列宽 512 字节,错误里可能
|
||||
// 带 URL,截断同时避免超长)。
|
||||
mediaProbeErrorLimit = 200
|
||||
)
|
||||
|
||||
// mediaProber 是 MediaProbeService 需要的探测能力。抽成接口是为了在测试里注入
|
||||
// 桩,避免依赖真实 ffprobe 二进制。
|
||||
type mediaProber interface {
|
||||
ProbeFull(ctx context.Context, input ProbeInput) (*FullProbeResult, error)
|
||||
}
|
||||
|
||||
// MediaProbeService 用 ffprobe 提取媒体的基础信息(容器、每路轨道、内嵌章节)
|
||||
// 并落库缓存。
|
||||
//
|
||||
// 定位是「播放时顺带补齐媒体信息」:STRM / 云盘媒体在扫描阶段拿不到时长,而
|
||||
// 播放链路要用缓存里的时长来换算「延续到片尾」这类区间,详情页将来也直接读这份
|
||||
// 媒体信息。
|
||||
//
|
||||
// 它**不参与片头/片尾判定**:章节标题绝大多数没有语义(生产库实测抽样 64 个
|
||||
// 文件,命中 0 个),拿它去猜跳过点只会给出错误的位置,时间轴数据仍然只信
|
||||
// TheIntroDB。
|
||||
//
|
||||
// 核心约束:一次探测要 2~4.5 秒(远端直链要跨洋跑几次 HTTP 事务),所以只允许
|
||||
// 异步跑,播放链路永远只读缓存。
|
||||
type MediaProbeService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
probe mediaProber
|
||||
// resolve 把 strm 播放目标解析成最终直链(含绑定 UA 的请求头)。与转码、
|
||||
// 内嵌字幕发现走同一条换链路径,否则会踩到网盘 CDN 的防盗链 403。
|
||||
resolve func(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error)
|
||||
|
||||
mu sync.Mutex
|
||||
inFlight map[string]struct{}
|
||||
}
|
||||
|
||||
// NewMediaProbeService is the constructor.
|
||||
func NewMediaProbeService(log *zap.Logger, repo *repository.Container, probe *FFprobeService) *MediaProbeService {
|
||||
svc := &MediaProbeService{
|
||||
log: log,
|
||||
repo: repo,
|
||||
inFlight: make(map[string]struct{}),
|
||||
}
|
||||
if probe != nil {
|
||||
svc.probe = probe
|
||||
}
|
||||
return svc
|
||||
}
|
||||
|
||||
// SetPlayTargetResolver injects the strm → direct-link resolver.
|
||||
func (s *MediaProbeService) SetPlayTargetResolver(resolve func(ctx context.Context, raw, userAgent string) (*StrmPlayResult, error)) *MediaProbeService {
|
||||
if s != nil {
|
||||
s.resolve = resolve
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// EnsureAsync 保证这部媒体的探测已排上队,并立刻返回。
|
||||
//
|
||||
// 已经有同一条媒体的探测在跑、或服务未配置好时返回 false;这次新排上一条返回
|
||||
// true——调用方据此告诉客户端「稍后再拉一次」。
|
||||
func (s *MediaProbeService) EnsureAsync(m *model.Media) bool {
|
||||
if s == nil || s.probe == nil || s.repo == nil || m == nil || strings.TrimSpace(m.ID) == "" {
|
||||
return false
|
||||
}
|
||||
if !s.reserve(m.ID) {
|
||||
return false
|
||||
}
|
||||
// 复制一份媒体行:调用方的对象可能属于请求作用域,后台协程不该继续引用它。
|
||||
snapshot := *m
|
||||
helper.Go(s.log, "service.mediaProbe", func() {
|
||||
defer s.release(snapshot.ID)
|
||||
s.run(context.Background(), &snapshot)
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *MediaProbeService) reserve(mediaID string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.inFlight == nil {
|
||||
s.inFlight = make(map[string]struct{})
|
||||
}
|
||||
if _, running := s.inFlight[mediaID]; running {
|
||||
return false
|
||||
}
|
||||
s.inFlight[mediaID] = struct{}{}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *MediaProbeService) release(mediaID string) {
|
||||
s.mu.Lock()
|
||||
delete(s.inFlight, mediaID)
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// run 执行一次探测并落库。它跑在后台,没有调用方能接收错误,所以任何失败都只
|
||||
// 记录、不外抛。
|
||||
func (s *MediaProbeService) run(ctx context.Context, m *model.Media) {
|
||||
ctx, cancel := context.WithTimeout(ctx, mediaProbeTimeout)
|
||||
defer cancel()
|
||||
|
||||
input, err := s.probeInput(ctx, m)
|
||||
if err != nil {
|
||||
s.markFailure(ctx, m.ID, err)
|
||||
return
|
||||
}
|
||||
result, err := s.probe.ProbeFull(ctx, input)
|
||||
if err != nil {
|
||||
s.markFailure(ctx, m.ID, err)
|
||||
return
|
||||
}
|
||||
if err := s.persistProbe(ctx, m, result); err != nil {
|
||||
s.markFailure(ctx, m.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
// probeInput 把媒体行解析成 ffprobe 能直接打开的输入。
|
||||
//
|
||||
// 本地文件给路径;STRM / 云盘先解析成最终直链并带上绑定的请求头——直链与 UA
|
||||
// 必须配套,用错会被 CDN 拒绝。
|
||||
func (s *MediaProbeService) probeInput(ctx context.Context, m *model.Media) (ProbeInput, error) {
|
||||
if m == nil {
|
||||
return ProbeInput{}, ErrMediaNotFound
|
||||
}
|
||||
if !isStrmMediaRow(m) {
|
||||
if _, err := os.Stat(m.Path); err != nil {
|
||||
return ProbeInput{}, ErrMediaNotFound
|
||||
}
|
||||
return ProbeInput{Source: m.Path}, nil
|
||||
}
|
||||
raw := strings.TrimSpace(m.STRMURL)
|
||||
if raw == "" && strings.HasSuffix(strings.ToLower(strings.TrimSpace(m.Path)), ".strm") {
|
||||
parsed, err := readLocalSTRMTarget(m.Path)
|
||||
if err == nil {
|
||||
raw = strings.TrimSpace(parsed)
|
||||
}
|
||||
}
|
||||
if raw == "" {
|
||||
return ProbeInput{}, errors.New("strm play target missing")
|
||||
}
|
||||
if s.resolve != nil {
|
||||
resolved, err := s.resolve(ctx, raw, "")
|
||||
if err != nil {
|
||||
return ProbeInput{}, err
|
||||
}
|
||||
in, err := transcodeInputFromPlayResult(resolved)
|
||||
if err != nil {
|
||||
return ProbeInput{}, err
|
||||
}
|
||||
return ProbeInput{Source: in.Source, Headers: in.Headers}, nil
|
||||
}
|
||||
if isHTTPPlaybackTarget(raw) {
|
||||
return ProbeInput{Source: raw}, nil
|
||||
}
|
||||
return ProbeInput{}, errors.New("strm probe source unavailable")
|
||||
}
|
||||
|
||||
// persistProbe 把一次成功的探测落库。
|
||||
func (s *MediaProbeService) persistProbe(ctx context.Context, m *model.Media, result *FullProbeResult) error {
|
||||
if s.repo == nil || s.repo.MediaProbe == nil {
|
||||
return errors.New("media probe repository not wired")
|
||||
}
|
||||
payload, err := result.PayloadJSON()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
row := &model.MediaProbe{
|
||||
MediaID: m.ID,
|
||||
Signature: mediaProbeSignature(m),
|
||||
Source: mediaProbeInputKind(m),
|
||||
Container: result.Container,
|
||||
DurationSec: result.DurationSec,
|
||||
BitRate: result.BitRate,
|
||||
Width: firstStreamDimension(result, "video", true),
|
||||
Height: firstStreamDimension(result, "video", false),
|
||||
VideoCodec: firstStreamCodec(result, "video"),
|
||||
AudioCodec: firstStreamCodec(result, "audio"),
|
||||
VideoStreams: len(result.StreamsOfType("video")),
|
||||
AudioStreams: len(result.StreamsOfType("audio")),
|
||||
SubtitleStreams: len(result.StreamsOfType("subtitle")),
|
||||
ChapterCount: len(result.Chapters),
|
||||
Payload: payload,
|
||||
ProbedAt: time.Now(),
|
||||
}
|
||||
if err := s.repo.MediaProbe.Upsert(ctx, row); err != nil {
|
||||
return err
|
||||
}
|
||||
s.backfillDuration(ctx, m, result.DurationSec)
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillDuration 把探测到的时长补进 media.duration_sec。STRM / 云盘媒体在扫描
|
||||
// 阶段拿不到时长,而末段区间(end_ms = 0)要靠它才能换算出真实结束时间。
|
||||
func (s *MediaProbeService) backfillDuration(ctx context.Context, m *model.Media, durationSec int) {
|
||||
if s.repo == nil || s.repo.DB == nil || m == nil || durationSec <= 0 || m.DurationSec > 0 {
|
||||
return
|
||||
}
|
||||
err := s.repo.DB.WithContext(ctx).Model(&model.Media{}).
|
||||
Where("id = ? AND duration_sec <= 0", m.ID).
|
||||
Update("duration_sec", durationSec).Error
|
||||
if err != nil && s.log != nil {
|
||||
s.log.Debug("backfill probed duration failed", zap.String("media_id", m.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MediaProbeService) markFailure(ctx context.Context, mediaID string, probeErr error) {
|
||||
if s == nil || s.repo == nil || probeErr == nil {
|
||||
return
|
||||
}
|
||||
message := truncateProbeError(probeErr)
|
||||
if err := s.repo.MediaProbe.MarkFailure(ctx, mediaID, message, time.Now()); err != nil && s.log != nil {
|
||||
s.log.Debug("record media probe failure failed", zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
if s.log != nil {
|
||||
s.log.Debug("media probe failed", zap.String("media_id", mediaID), zap.Error(probeErr))
|
||||
}
|
||||
}
|
||||
|
||||
// truncateProbeError 限制错误信息长度。错误里可能带被拒绝的直链,落库时截断,
|
||||
// 避免超长并减少敏感内容。
|
||||
func truncateProbeError(err error) string {
|
||||
message := strings.TrimSpace(err.Error())
|
||||
runes := []rune(message)
|
||||
if len(runes) > mediaProbeErrorLimit {
|
||||
return string(runes[:mediaProbeErrorLimit])
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
// mediaProbeInputKind 记录输入形态,供详情页判断「这个时长是本地读的还是远端读的」。
|
||||
func mediaProbeInputKind(m *model.Media) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
if isStrmMediaRow(m) {
|
||||
return "strm"
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
// mediaProbeSignature 是「探的是哪个文件」的指纹。
|
||||
//
|
||||
// 本地文件用路径 + 大小 + 修改时间;STRM / 云盘没有本地文件,用固化的播放目标,
|
||||
// 且绝不能用解析后的直链(每次签名都不同,缓存会永远失效)。整体做哈希:
|
||||
// 既固定长度,也不把路径或 pickcode 再抄一份进数据库。
|
||||
func mediaProbeSignature(m *model.Media) string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
var base string
|
||||
if isStrmMediaRow(m) {
|
||||
target := strings.TrimSpace(m.STRMURL)
|
||||
if target == "" {
|
||||
target = strings.TrimSpace(m.Path)
|
||||
}
|
||||
base = "strm|" + target
|
||||
} else if info, err := os.Stat(m.Path); err == nil {
|
||||
base = fmt.Sprintf("local|%s|%d|%d", m.Path, info.Size(), info.ModTime().Unix())
|
||||
} else {
|
||||
base = "local|" + m.Path
|
||||
}
|
||||
sum := sha256.Sum256([]byte(base))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func firstStreamCodec(result *FullProbeResult, kind string) string {
|
||||
if result == nil {
|
||||
return ""
|
||||
}
|
||||
for _, stream := range result.Streams {
|
||||
if stream.Type == kind && stream.Codec != "" {
|
||||
return stream.Codec
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// firstStreamDimension 取第一路指定类型轨道的宽(width=true)或高。
|
||||
func firstStreamDimension(result *FullProbeResult, kind string, width bool) int {
|
||||
if result == nil {
|
||||
return 0
|
||||
}
|
||||
for _, stream := range result.Streams {
|
||||
if stream.Type != kind {
|
||||
continue
|
||||
}
|
||||
if width {
|
||||
return stream.Width
|
||||
}
|
||||
return stream.Height
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
)
|
||||
|
||||
// stubProber 是 mediaProber 的测试桩:可以拦住探测(gate)用来验证并发去重,
|
||||
// 也可以直接返回预置结果或错误。
|
||||
type stubProber struct {
|
||||
gate chan struct{}
|
||||
result *FullProbeResult
|
||||
err error
|
||||
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
last ProbeInput
|
||||
}
|
||||
|
||||
func (s *stubProber) ProbeFull(_ context.Context, input ProbeInput) (*FullProbeResult, error) {
|
||||
s.mu.Lock()
|
||||
s.calls++
|
||||
s.last = input
|
||||
gate := s.gate
|
||||
result := s.result
|
||||
err := s.err
|
||||
s.mu.Unlock()
|
||||
if gate != nil {
|
||||
<-gate
|
||||
}
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *stubProber) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.calls
|
||||
}
|
||||
|
||||
func (s *stubProber) lastInput() ProbeInput {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.last
|
||||
}
|
||||
|
||||
func chapterProbeResult() *FullProbeResult {
|
||||
return &FullProbeResult{
|
||||
Container: "matroska,webm",
|
||||
DurationSec: 1451,
|
||||
BitRate: 8_000_000,
|
||||
Streams: []ProbeStream{
|
||||
{Index: 0, Type: "video", Codec: "hevc", Width: 3840, Height: 2160},
|
||||
{Index: 1, Type: "audio", Codec: "eac3"},
|
||||
{Index: 2, Type: "subtitle", Codec: "ass"},
|
||||
},
|
||||
Chapters: []ProbeChapter{
|
||||
{Index: 0, StartMs: 0, EndMs: 95_000, Title: "Chapter 01"},
|
||||
{Index: 1, StartMs: 228_664, EndMs: 246_143, Title: "Opening"},
|
||||
{Index: 2, StartMs: 3_431_000, EndMs: 0, Title: "End Credits"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// newProbeFixture 建一条本地媒体(真实落在临时目录里,因为 probeInput 会 stat
|
||||
// 它)和一个可注入桩的 MediaProbeService。
|
||||
func newProbeFixture(t *testing.T) (*MediaProbeService, *repository.Container, *model.Media) {
|
||||
t.Helper()
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "S01E01.mkv")
|
||||
if err := os.WriteFile(path, []byte("not-really-a-video"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &model.Media{
|
||||
Base: model.Base{ID: "ep-1"},
|
||||
LibraryID: "lib-anime",
|
||||
SeriesID: "s-1",
|
||||
Title: "某剧",
|
||||
Path: path,
|
||||
SeasonNum: 1,
|
||||
EpisodeNum: 1,
|
||||
}
|
||||
if err := repos.DB.Create(m).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := NewMediaProbeService(zap.NewNop(), repos, nil)
|
||||
return svc, repos, m
|
||||
}
|
||||
|
||||
func waitForCondition(t *testing.T, timeout time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("condition was not met before the deadline")
|
||||
}
|
||||
|
||||
func TestMediaProbeRunPersistsProbeRow(t *testing.T) {
|
||||
svc, repos, m := newProbeFixture(t)
|
||||
prober := &stubProber{result: chapterProbeResult()}
|
||||
svc.probe = prober
|
||||
|
||||
// 直接调 run(而不是 EnsureAsync)以获得确定性的断言:这里要验证的是落库,
|
||||
// 不是调度。
|
||||
svc.run(context.Background(), m)
|
||||
|
||||
if got := prober.lastInput().Source; got != m.Path {
|
||||
t.Fatalf("probe source = %q, want the local path %q", got, m.Path)
|
||||
}
|
||||
|
||||
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe == nil {
|
||||
t.Fatal("probe row missing")
|
||||
}
|
||||
if probe.Container != "matroska,webm" || probe.DurationSec != 1451 || probe.ChapterCount != 3 {
|
||||
t.Fatalf("probe summary = %#v", probe)
|
||||
}
|
||||
if probe.VideoStreams != 1 || probe.AudioStreams != 1 || probe.SubtitleStreams != 1 {
|
||||
t.Fatalf("stream counts = %d/%d/%d", probe.VideoStreams, probe.AudioStreams, probe.SubtitleStreams)
|
||||
}
|
||||
if probe.Width != 3840 || probe.Height != 2160 || probe.VideoCodec != "hevc" || probe.AudioCodec != "eac3" {
|
||||
t.Fatalf("probe primaries = %#v", probe)
|
||||
}
|
||||
if probe.Source != "local" || probe.Signature == "" {
|
||||
t.Fatalf("probe source/signature = %q/%q", probe.Source, probe.Signature)
|
||||
}
|
||||
if probe.LastError != "" {
|
||||
t.Fatalf("last error = %q, want empty", probe.LastError)
|
||||
}
|
||||
if !strings.Contains(probe.Payload, "hevc") {
|
||||
t.Fatalf("payload should carry the parsed streams: %s", probe.Payload)
|
||||
}
|
||||
|
||||
// 时长回填:STRM / 云盘媒体扫描时拿不到时长,末段区间要靠它换算结束时间。
|
||||
var refreshed model.Media
|
||||
if err := repos.DB.First(&refreshed, "id = ?", m.ID).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if refreshed.DurationSec != 1451 {
|
||||
t.Fatalf("media duration = %d, want the probed 1451", refreshed.DurationSec)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeFailureKeepsPreviousSummary(t *testing.T) {
|
||||
svc, repos, m := newProbeFixture(t)
|
||||
prober := &stubProber{result: chapterProbeResult()}
|
||||
svc.probe = prober
|
||||
svc.run(context.Background(), m)
|
||||
|
||||
prober.err = errors.New("ffprobe full: exit status 1")
|
||||
svc.run(context.Background(), m)
|
||||
|
||||
probe, err := repos.MediaProbe.Get(t.Context(), m.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probe == nil || probe.LastError == "" {
|
||||
t.Fatal("a failed re-probe must record the error")
|
||||
}
|
||||
// 关键:失败只更新时间与错误信息,上一次成功的媒体信息必须留着——
|
||||
// 否则一次失败的重探会把已经拿到的时长(片尾区间换算要用)抹掉。
|
||||
if probe.DurationSec != 1451 || probe.Container != "matroska,webm" || probe.Payload == "" {
|
||||
t.Fatalf("a failed re-probe wiped the previous summary: %#v", probe)
|
||||
}
|
||||
}
|
||||
|
||||
// 探测有结论后就不再重探;失败要等冷却期过去才允许重试。
|
||||
func TestMediaProbeSettledSkipsReprobeButStaleFailureRetries(t *testing.T) {
|
||||
if !mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now()}) {
|
||||
t.Fatal("a completed probe is settled")
|
||||
}
|
||||
if !mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now(), LastError: "boom"}) {
|
||||
t.Fatal("a recent failure is settled: it must not be retried on every play")
|
||||
}
|
||||
if mediaProbeSettled(&model.MediaProbe{ProbedAt: time.Now().Add(-mediaProbeFailureRetry - time.Minute), LastError: "boom"}) {
|
||||
t.Fatal("a failure past the cooldown should be retried")
|
||||
}
|
||||
if mediaProbeSettled(nil) {
|
||||
t.Fatal("a missing probe row is not settled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeEnsureAsyncDedupesInFlightProbes(t *testing.T) {
|
||||
svc, _, m := newProbeFixture(t)
|
||||
gate := make(chan struct{})
|
||||
prober := &stubProber{result: chapterProbeResult(), gate: gate}
|
||||
svc.probe = prober
|
||||
|
||||
if !svc.EnsureAsync(m) {
|
||||
t.Fatal("first EnsureAsync should enqueue a probe")
|
||||
}
|
||||
// 同一条媒体在跑的时候不能重复排队:否则每次播放请求都会再起一次 3 秒探测。
|
||||
if svc.EnsureAsync(m) {
|
||||
t.Fatal("second EnsureAsync must report that a probe is already running")
|
||||
}
|
||||
|
||||
close(gate)
|
||||
waitForCondition(t, 5*time.Second, func() bool {
|
||||
svc.mu.Lock()
|
||||
defer svc.mu.Unlock()
|
||||
return len(svc.inFlight) == 0
|
||||
})
|
||||
if got := prober.callCount(); got != 1 {
|
||||
t.Fatalf("probe calls = %d, want 1", got)
|
||||
}
|
||||
// 跑完之后允许再次排队(例如失败冷却期到了之后的重试)。
|
||||
if !svc.EnsureAsync(m) {
|
||||
t.Fatal("EnsureAsync should enqueue again once the previous run finished")
|
||||
}
|
||||
waitForCondition(t, 5*time.Second, func() bool { return prober.callCount() == 2 })
|
||||
}
|
||||
|
||||
func TestMediaProbeEnsureAsyncWithoutProberIsNotPending(t *testing.T) {
|
||||
svc, _, m := newProbeFixture(t)
|
||||
// probe 未注入(ffprobe 不可用时就是这样):不能谎报「提取中」,否则客户端
|
||||
// 会白轮询一轮。
|
||||
if svc.EnsureAsync(m) {
|
||||
t.Fatal("EnsureAsync must return false without a prober")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeInputRejectsMissingLocalFile(t *testing.T) {
|
||||
svc, _, _ := newProbeFixture(t)
|
||||
_, err := svc.probeInput(t.Context(), &model.Media{
|
||||
Base: model.Base{ID: "missing"}, Path: filepath.Join(t.TempDir(), "nope.mkv"),
|
||||
})
|
||||
if !errors.Is(err, ErrMediaNotFound) {
|
||||
t.Fatalf("err = %v, want ErrMediaNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMediaProbeInputResolvesStrmWithHeaders(t *testing.T) {
|
||||
svc := &MediaProbeService{}
|
||||
svc.SetPlayTargetResolver(func(_ context.Context, raw, userAgent string) (*StrmPlayResult, error) {
|
||||
if raw != "https://pan.example.com/api/strm/play/115?v=1" {
|
||||
t.Fatalf("resolver got raw = %q", raw)
|
||||
}
|
||||
if userAgent != "" {
|
||||
t.Fatalf("userAgent = %q, want empty for a background probe", userAgent)
|
||||
}
|
||||
return &StrmPlayResult{RedirectURL: "https://cdn.example.com/a.mkv"}, nil
|
||||
})
|
||||
input, err := svc.probeInput(t.Context(), &model.Media{
|
||||
Base: model.Base{ID: "strm-1"},
|
||||
Path: "/media/a.mkv.strm",
|
||||
STRMURL: "https://pan.example.com/api/strm/play/115?v=1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("probeInput: %v", err)
|
||||
}
|
||||
if input.Source != "https://cdn.example.com/a.mkv" {
|
||||
t.Fatalf("source = %q, want the resolved direct link", input.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// 签名不能把播放目标(含 pickcode)再抄一份进数据库,所以整体做哈希。
|
||||
func TestMediaProbeSignatureHidesPlayTargetAndTracksFileChanges(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "movie.mkv")
|
||||
if err := os.WriteFile(path, []byte("v1"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local := &model.Media{Base: model.Base{ID: "l-1"}, Path: path}
|
||||
first := mediaProbeSignature(local)
|
||||
if len(first) != 32 {
|
||||
t.Fatalf("signature = %q, want a 32-char hex digest", first)
|
||||
}
|
||||
if strings.Contains(first, "movie.mkv") {
|
||||
t.Fatalf("signature leaked the path: %q", first)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("v2-changed-size"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mediaProbeSignature(local) == first {
|
||||
t.Fatal("the signature must change when the file changes")
|
||||
}
|
||||
|
||||
strm := &model.Media{
|
||||
Base: model.Base{ID: "s-1"},
|
||||
Path: "/media/movie.mkv.strm",
|
||||
STRMURL: "https://pan.example.com/api/strm/play/115?pickcode=secret-pickcode",
|
||||
}
|
||||
strmSig := mediaProbeSignature(strm)
|
||||
if strings.Contains(strmSig, "secret-pickcode") || strings.Contains(strmSig, "pan.example.com") {
|
||||
t.Fatalf("strm signature leaked the play target: %q", strmSig)
|
||||
}
|
||||
if strmSig == "" {
|
||||
t.Fatal("strm signature must not be empty")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
@@ -43,6 +44,9 @@ type MediaSegmentService struct {
|
||||
log *zap.Logger
|
||||
repo *repository.Container
|
||||
introdb *IntroDBService
|
||||
// probe 负责从文件内嵌章节里提取片头/片尾(异步、落库)。未注入时只用
|
||||
// TheIntroDB。
|
||||
probe *MediaProbeService
|
||||
}
|
||||
|
||||
// NewMediaSegmentService is the constructor.
|
||||
@@ -58,16 +62,67 @@ func (s *MediaSegmentService) SetIntroDB(p *IntroDBService) *MediaSegmentService
|
||||
return s
|
||||
}
|
||||
|
||||
// SetProbe wires the in-file chapter extractor. Without it the ffprobe source
|
||||
// simply yields nothing and auto falls back to TheIntroDB.
|
||||
func (s *MediaSegmentService) SetProbe(p *MediaProbeService) *MediaSegmentService {
|
||||
if s != nil {
|
||||
s.probe = p
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// ListForPlayback returns the segments known for a media item, refreshing from
|
||||
// the provider when the cache is stale.
|
||||
//
|
||||
// 它不做任何阻塞起播的事情——调用方是在播放已经开始之后用一次独立请求进来的,
|
||||
// 抓取失败也只是少一个「跳过片头」按钮,绝不能让播放报错。
|
||||
//
|
||||
// 时间轴数据只来自 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 nil, nil
|
||||
}
|
||||
cached, err := s.repo.MediaSegment.ListByMedia(ctx, m.ID)
|
||||
// 用 defer 保证探测一定在本次请求所有数据库读写之后才启动:后台探测自己也要
|
||||
// 写库,若在本次写事务还没结束时启动,两个写事务会抢同一把锁(SQLite 下就是
|
||||
// SQLITE_BUSY,实测能直接把社区库的落库打失败)。
|
||||
defer s.ensureMediaProbe(ctx, m)
|
||||
return s.introDBSegments(ctx, m)
|
||||
}
|
||||
|
||||
// ensureMediaProbe 在还没探过(或上次失败已过冷却期)时起一次异步探测。
|
||||
//
|
||||
// 它只为「补齐媒体信息」服务:失败只是拿不到时长,不影响播放,也不影响片段。
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// introDBSegments 读社区库的片段,缓存过期时按调用方的预算抓一次并落库。
|
||||
func (s *MediaSegmentService) introDBSegments(ctx context.Context, m *model.Media) ([]model.MediaSegment, error) {
|
||||
cached, err := s.repo.MediaSegment.ListByMediaSource(ctx, m.ID, IntroDBSource)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -90,6 +145,13 @@ func (s *MediaSegmentService) ListForPlayback(ctx context.Context, m *model.Medi
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (s *MediaSegmentService) debug(message, mediaID string, err error) {
|
||||
if s == nil || s.log == nil {
|
||||
return
|
||||
}
|
||||
s.log.Debug(message, zap.String("media_id", mediaID), zap.Error(err))
|
||||
}
|
||||
|
||||
// refresh 向提供方查询并落库,返回 (rows, 是否真的发起过查询, error)。
|
||||
//
|
||||
// attempted=false 表示这部媒体缺少可查询的外部 ID(最常见的原因是还没刮削,
|
||||
@@ -151,19 +213,19 @@ func (s *MediaSegmentService) refresh(ctx context.Context, m *model.Media) ([]mo
|
||||
return rows, true, nil
|
||||
}
|
||||
|
||||
// queryIDs resolves the provider query key. Movies use their own TMDb id;
|
||||
// episodes need the *series* TMDb id plus season/episode, because scraping
|
||||
// stores the episode-level TMDb id on Media.TMDbID.
|
||||
// queryIDs resolves the provider query key. Movies use their own TMDb id.
|
||||
//
|
||||
// 剧集需要「剧集级」TMDb id 加季/集。优先取 Series.TMDbID;但有些刮削路径
|
||||
// 不建 Series 行,而是把剧集级 id 直接写在 Media.TMDbID 上(生产环境动漫库
|
||||
// 实测如此:同一剧名下各集共用同一个 id,52/52 个剧名都唯一)。这类行原先
|
||||
// 一律解析不出 id,等于整库查不到任何片段,所以这里补一条兜底。
|
||||
//
|
||||
// 兜底必须验证「是不是剧集级 id」:Media.TMDbID 在另一些刮削路径下存的是
|
||||
// 单集自己的 id,拿它去查会命中别的片子。判据是多集共用(见
|
||||
// MediaRepository.ExistsSiblingWithTMDbID)——单集 id 不会在兄弟集上重复。
|
||||
func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
|
||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||
if m.SeriesID == "" || m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
series, err := s.repo.Series.FindByID(ctx, m.SeriesID)
|
||||
if err != nil || series == nil || series.TMDbID <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return series.TMDbID, m.SeasonNum, m.EpisodeNum
|
||||
return s.episodeQueryIDs(ctx, m)
|
||||
}
|
||||
if m.TMDbID > 0 {
|
||||
return m.TMDbID, 0, 0
|
||||
@@ -171,6 +233,41 @@ func (s *MediaSegmentService) queryIDs(ctx context.Context, m *model.Media) (tmd
|
||||
return 0, 0, 0
|
||||
}
|
||||
|
||||
func (s *MediaSegmentService) episodeQueryIDs(ctx context.Context, m *model.Media) (tmdbID, season, episode int) {
|
||||
if m.SeasonNum <= 0 || m.EpisodeNum <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
if seriesTMDbID := s.seriesTMDbID(ctx, m); seriesTMDbID > 0 {
|
||||
return seriesTMDbID, m.SeasonNum, m.EpisodeNum
|
||||
}
|
||||
if !s.mediaTMDbIDLooksLikeSeries(ctx, m) {
|
||||
return 0, 0, 0
|
||||
}
|
||||
return m.TMDbID, m.SeasonNum, m.EpisodeNum
|
||||
}
|
||||
|
||||
// seriesTMDbID returns the series-level TMDb id, or 0 when the row has no
|
||||
// Series association or that Series was never matched.
|
||||
func (s *MediaSegmentService) seriesTMDbID(ctx context.Context, m *model.Media) int {
|
||||
if strings.TrimSpace(m.SeriesID) == "" {
|
||||
return 0
|
||||
}
|
||||
series, err := s.repo.Series.FindByID(ctx, m.SeriesID)
|
||||
if err != nil || series == nil || series.TMDbID <= 0 {
|
||||
return 0
|
||||
}
|
||||
return series.TMDbID
|
||||
}
|
||||
|
||||
// mediaTMDbIDLooksLikeSeries reports whether Media.TMDbID can stand in for the
|
||||
// series id: only an id shared by other episodes of the same show qualifies.
|
||||
func (s *MediaSegmentService) mediaTMDbIDLooksLikeSeries(ctx context.Context, m *model.Media) bool {
|
||||
if s == nil || s.repo == nil || m == nil || m.TMDbID <= 0 {
|
||||
return false
|
||||
}
|
||||
return s.repo.Media.ExistsSiblingWithTMDbID(ctx, m)
|
||||
}
|
||||
|
||||
// ledgerFresh reports whether a previous lookup is still within its TTL.
|
||||
func ledgerFresh(row *model.MediaSegmentFetch) bool {
|
||||
if row == nil {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,131 @@ func TestQueryIDsIsNotResolvableBeforeScrape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 部分刮削路径(生产环境动漫库实测如此)不建 Series 行,而是把「剧集级」
|
||||
// TMDb id 直接写在 Media.TMDbID 上:同一剧名下各集共用同一个 id。
|
||||
// 原先这类行一律解析不出 id,整个动漫库等于查不到任何片段。
|
||||
func TestQueryIDsFallsBackToMediaTMDbWhenSiblingsShareIt(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-6"}, LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||||
{Base: model.Base{ID: "ep-7"}, LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
tmdbID, season, episode := svc.queryIDs(ctx, episodes[0])
|
||||
if tmdbID != 61970 || season != 1 || episode != 6 {
|
||||
t.Fatalf("query = (%d,%d,%d), want (61970,1,6): a shared id is a series id", tmdbID, season, episode)
|
||||
}
|
||||
}
|
||||
|
||||
// 反例(重要):另一些刮削路径把「单集自己的」id 写在 Media.TMDbID 上,
|
||||
// 每集都不同。这种 id 不能当剧集 id 用——拿它去查会命中完全不相干的片子。
|
||||
func TestQueryIDsRejectsPerEpisodeTMDbWithoutSiblings(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-1"}, LibraryID: "lib-tv", Title: "某剧", Path: "/tv/some/S01E01.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 4_375_419},
|
||||
{Base: model.Base{ID: "ep-2"}, LibraryID: "lib-tv", Title: "某剧", Path: "/tv/some/S01E02.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 4_375_420},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, episodes[0]); tmdbID != 0 {
|
||||
t.Fatalf("tmdbID = %d, want 0: a per-episode id must not be used as a series id", tmdbID)
|
||||
}
|
||||
}
|
||||
|
||||
// Series 行存在时永远优先,哪怕 Media.TMDbID 看起来也像个共用 id。
|
||||
func TestQueryIDsPrefersSeriesTMDbOverSharedMediaTMDb(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := repos.DB.Create(&model.Series{
|
||||
Base: model.Base{ID: "s-1"}, Title: "便·当", TMDbID: 1396,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-a"}, SeriesID: "s-1", LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||||
{Base: model.Base{ID: "ep-b"}, SeriesID: "s-1", LibraryID: "lib-anime", Title: "便·当", Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, episodes[0]); tmdbID != 1396 {
|
||||
t.Fatalf("tmdbID = %d, want the Series id 1396", tmdbID)
|
||||
}
|
||||
}
|
||||
|
||||
// 关联了 Series 但那条 Series 没刮到 id 时,仍然走 Media.TMDbID 兜底。
|
||||
func TestQueryIDsFallsBackWhenSeriesHasNoTMDb(t *testing.T) {
|
||||
repos := repository.New(newServiceTestDB(t))
|
||||
svc := NewMediaSegmentService(zap.NewNop(), repos)
|
||||
ctx := t.Context()
|
||||
|
||||
if err := repos.DB.Create(&model.Series{
|
||||
Base: model.Base{ID: "s-2"}, Title: "便·当", TMDbID: 0,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-c"}, SeriesID: "s-2", Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||||
{Base: model.Base{ID: "ep-d"}, SeriesID: "s-2", Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if tmdbID, _, _ := svc.queryIDs(ctx, episodes[0]); tmdbID != 61970 {
|
||||
t.Fatalf("tmdbID = %d, want the shared Media id 61970", tmdbID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackQueriesEpisodesWithSharedSeriesTMDb(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBTVPayload))
|
||||
ctx := t.Context()
|
||||
episodes := []*model.Media{
|
||||
{Base: model.Base{ID: "ep-x"}, LibraryID: "lib-anime", Title: "便·当",
|
||||
Path: "/anime/ben-to/S01E06.mkv", SeasonNum: 1, EpisodeNum: 6, TMDbID: 61970},
|
||||
{Base: model.Base{ID: "ep-y"}, LibraryID: "lib-anime", Title: "便·当",
|
||||
Path: "/anime/ben-to/S01E07.mkv", SeasonNum: 1, EpisodeNum: 7, TMDbID: 61970},
|
||||
}
|
||||
for _, ep := range episodes {
|
||||
if err := repos.DB.Create(ep).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := svc.ListForPlayback(ctx, episodes[0])
|
||||
if err != nil {
|
||||
t.Fatalf("call: %v", err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("rows = %#v, want the two spans the provider returned", rows)
|
||||
}
|
||||
if got := atomic.LoadInt32(calls); got != 1 {
|
||||
t.Fatalf("provider calls = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListForPlaybackFetchesOnceThenServesCache(t *testing.T) {
|
||||
svc, repos, calls := newSegmentServiceFixture(t, writeJSONBody(introDBMoviePayload))
|
||||
ctx := t.Context()
|
||||
|
||||
@@ -36,6 +36,7 @@ type Container struct {
|
||||
Scraper *ScraperService
|
||||
Playback *PlaybackService
|
||||
Segments *MediaSegmentService
|
||||
MediaProbe *MediaProbeService
|
||||
ImageProxy *ImageProxy
|
||||
Watcher *WatcherService
|
||||
Subtitle *SubtitleService
|
||||
|
||||
@@ -147,6 +147,11 @@ func (b *serviceContainerBuilder) initContentServices() {
|
||||
b.c.Transcoder.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
b.c.Transcoder.SetProbe(b.c.FFprobe)
|
||||
b.c.Subtitle.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTarget)
|
||||
// 播放时的媒体信息提取(ffprobe 章节 → 跳过片头/片尾):完全异步,播放链路
|
||||
// 只读缓存。换链复用与转码/字幕同一条路径,避免 CDN 防盗链 403。
|
||||
b.c.MediaProbe = NewMediaProbeService(b.log, b.repos, b.c.FFprobe).
|
||||
SetPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
|
||||
b.c.Segments.SetProbe(b.c.MediaProbe)
|
||||
// 播放链路:/Videos/{id}/stream 与 /api/stream/{id} 在服务端完成换链后直接
|
||||
// 302 到最终直链,客户端少跟随一次 302(高延迟线路上省一个往返)。
|
||||
b.c.Stream.SetStrmPlayTargetResolver(b.c.Strm.ResolvePlayTargetWithUA)
|
||||
|
||||
Reference in New Issue
Block a user