mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-11 15:56:37 +08:00
bug处理
This commit is contained in:
@@ -105,8 +105,8 @@ func TestEmbyNextUpReturnsNextEpisode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
if watched {
|
if watched {
|
||||||
h := &model.PlaybackHistory{
|
h := &model.PlaybackHistory{
|
||||||
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
|
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||||
WatchedAt: watchedAt, Completed: false,
|
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完
|
||||||
}
|
}
|
||||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -127,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。
|
// 没有历史时必须返回合法空信封,不能 404/500。
|
||||||
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
|
func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) {
|
||||||
router, _, _ := newEmbyDiscoveryEnv(t)
|
router, _, _ := newEmbyDiscoveryEnv(t)
|
||||||
@@ -267,8 +310,8 @@ func TestEmbyNextUpFiltersBySeriesID(t *testing.T) {
|
|||||||
case 1:
|
case 1:
|
||||||
watchedID = m.ID
|
watchedID = m.ID
|
||||||
h := &model.PlaybackHistory{
|
h := &model.PlaybackHistory{
|
||||||
UserID: userID, MediaID: m.ID, PositionMs: 1000, DurationMs: 2000,
|
UserID: userID, MediaID: m.ID, PositionMs: 2000, DurationMs: 2000,
|
||||||
WatchedAt: watchedAt, Completed: false,
|
WatchedAt: watchedAt, Completed: true, // 第 1 集已看完,下一集是 S1E2
|
||||||
}
|
}
|
||||||
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
if err := svc.Repo.DB.Create(h).Error; err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
@@ -143,9 +143,11 @@ func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesI
|
|||||||
}
|
}
|
||||||
|
|
||||||
var current *model.Media
|
var current *model.Media
|
||||||
|
currentCompleted := false
|
||||||
for i := range hist {
|
for i := range hist {
|
||||||
if m := epByID[hist[i].MediaID]; m != nil {
|
if m := epByID[hist[i].MediaID]; m != nil {
|
||||||
current = m
|
current = m
|
||||||
|
currentCompleted = hist[i].Completed
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +169,7 @@ func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesI
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
next, ok := pickNextEpisode(episodes, current, completed)
|
next, ok := pickNextEpisode(episodes, current, currentCompleted, completed)
|
||||||
if !ok {
|
if !ok {
|
||||||
return emptyItemsEnvelope(0), nil
|
return emptyItemsEnvelope(0), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 回归:挂载的远程 Emby 剧集里,只看了几秒就退出的那一集必须仍是 NextUp 的结果。
|
||||||
|
// 客户端(Yamby 等)剧集详情页的「继续播放」直接取 NextUp 第一条,跳集会播错集。
|
||||||
|
func TestMountedRemoteNextUpKeepsPartiallyWatchedEpisode(t *testing.T) {
|
||||||
|
episode := func(id string, index int) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"Id": id,
|
||||||
|
"Name": "第" + strconv.Itoa(index) + "集",
|
||||||
|
"Type": "Episode",
|
||||||
|
"SeriesId": "series-100",
|
||||||
|
"ParentIndexNumber": 1,
|
||||||
|
"IndexNumber": index,
|
||||||
|
"RunTimeTicks": 14400640000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/emby/Users/uid-1/Items/series-100":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
|
||||||
|
case "/emby/Users/uid-1/Items/ep-2":
|
||||||
|
_ = json.NewEncoder(w).Encode(episode("ep-2", 2))
|
||||||
|
case "/emby/Users/uid-1/Items":
|
||||||
|
q := r.URL.Query()
|
||||||
|
if q.Get("IncludeItemTypes") != "Episode" || q.Get("ParentId") != "series-100" {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"TotalRecordCount": 3,
|
||||||
|
"Items": []map[string]any{
|
||||||
|
episode("ep-1", 1),
|
||||||
|
episode("ep-2", 2),
|
||||||
|
episode("ep-3", 3),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||||
|
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"url": server.URL,
|
||||||
|
"api_key": "test-api-key",
|
||||||
|
"remote_user_id": "uid-1",
|
||||||
|
})
|
||||||
|
acct := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-1"},
|
||||||
|
Name: "远程 Emby",
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
mount := &model.EmbyMount{
|
||||||
|
Base: model.Base{ID: "mount-1"},
|
||||||
|
AccountID: acct.ID,
|
||||||
|
RemoteViewID: "view-1",
|
||||||
|
RemoteViewName: "新番连载",
|
||||||
|
CollectionType: "tvshows",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||||
|
t.Fatalf("create mount: %v", err)
|
||||||
|
}
|
||||||
|
user := &model.User{
|
||||||
|
Base: model.Base{ID: "user-1"},
|
||||||
|
Username: "viewer",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "user",
|
||||||
|
Tier: "free",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 集只播了 3.5 秒就退出:有进度、未标记看完。
|
||||||
|
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||||
|
UserID: user.ID,
|
||||||
|
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
|
||||||
|
PositionMs: 3582,
|
||||||
|
DurationMs: 1440064,
|
||||||
|
WatchedAt: time.Now(),
|
||||||
|
Completed: false,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope, err := svc.NextUp(t.Context(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextUp: %v", err)
|
||||||
|
}
|
||||||
|
items, _ := envelope["Items"].([]map[string]any)
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
|
||||||
|
}
|
||||||
|
wantID := EncodeEmbyRemoteID(mount.ID, "ep-2")
|
||||||
|
if id, _ := items[0]["Id"].(string); id != wantID {
|
||||||
|
t.Fatalf("Id = %q, want %q (未看完的那一集不能被跳过)", id, wantID)
|
||||||
|
}
|
||||||
|
userData, _ := items[0]["UserData"].(map[string]any)
|
||||||
|
if ticks, _ := userData["PlaybackPositionTicks"].(int64); ticks != 35820000 {
|
||||||
|
t.Fatalf("PlaybackPositionTicks = %#v, want 35820000 (详情页要能续播到 3.5 秒)", userData["PlaybackPositionTicks"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 远程剧集已看完当前一集时,NextUp 仍要指向下一集。
|
||||||
|
func TestMountedRemoteNextUpAfterCompletedEpisode(t *testing.T) {
|
||||||
|
episode := func(id string, index int) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"Id": id,
|
||||||
|
"Name": "第" + strconv.Itoa(index) + "集",
|
||||||
|
"Type": "Episode",
|
||||||
|
"SeriesId": "series-100",
|
||||||
|
"ParentIndexNumber": 1,
|
||||||
|
"IndexNumber": index,
|
||||||
|
"RunTimeTicks": 14400640000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/emby/Users/uid-1/Items/series-100":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "series-100", "Name": "剧一", "Type": "Series"})
|
||||||
|
case "/emby/Users/uid-1/Items/ep-3":
|
||||||
|
_ = json.NewEncoder(w).Encode(episode("ep-3", 3))
|
||||||
|
case "/emby/Users/uid-1/Items":
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"TotalRecordCount": 3,
|
||||||
|
"Items": []map[string]any{
|
||||||
|
episode("ep-1", 1),
|
||||||
|
episode("ep-2", 2),
|
||||||
|
episode("ep-3", 3),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}, &model.PlaybackHistory{}, &model.User{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
|
||||||
|
cfg := &config.Config{}
|
||||||
|
remote := NewEmbyRemoteService(cfg, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||||
|
svc := NewEmbyService(cfg, zap.NewNop(), repos).SetEmbyRemote(remote)
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"url": server.URL,
|
||||||
|
"api_key": "test-api-key",
|
||||||
|
"remote_user_id": "uid-1",
|
||||||
|
})
|
||||||
|
acct := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-1"},
|
||||||
|
Name: "远程 Emby",
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||||
|
t.Fatalf("create account: %v", err)
|
||||||
|
}
|
||||||
|
mount := &model.EmbyMount{
|
||||||
|
Base: model.Base{ID: "mount-1"},
|
||||||
|
AccountID: acct.ID,
|
||||||
|
RemoteViewID: "view-1",
|
||||||
|
RemoteViewName: "新番连载",
|
||||||
|
CollectionType: "tvshows",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.EmbyMount.Create(t.Context(), mount); err != nil {
|
||||||
|
t.Fatalf("create mount: %v", err)
|
||||||
|
}
|
||||||
|
user := &model.User{
|
||||||
|
Base: model.Base{ID: "user-1"},
|
||||||
|
Username: "viewer",
|
||||||
|
PasswordHash: "x",
|
||||||
|
Role: "user",
|
||||||
|
Tier: "free",
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := repos.User.Create(t.Context(), user); err != nil {
|
||||||
|
t.Fatalf("create user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第 2 集已整集看完。
|
||||||
|
if err := repos.DB.Create(&model.PlaybackHistory{
|
||||||
|
UserID: user.ID,
|
||||||
|
MediaID: EncodeEmbyRemoteID(mount.ID, "ep-2"),
|
||||||
|
PositionMs: 1440064,
|
||||||
|
DurationMs: 1440064,
|
||||||
|
WatchedAt: time.Now(),
|
||||||
|
Completed: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("create history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope, err := svc.NextUp(context.Background(), user.ID, EncodeEmbyRemoteID(mount.ID, "series-100"), 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NextUp: %v", err)
|
||||||
|
}
|
||||||
|
items, _ := envelope["Items"].([]map[string]any)
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("items = %d, want 1 (%#v)", len(items), envelope)
|
||||||
|
}
|
||||||
|
if id, _ := items[0]["Id"].(string); id != EncodeEmbyRemoteID(mount.ID, "ep-3") {
|
||||||
|
t.Fatalf("Id = %q, want ep-3", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,10 +212,11 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
|||||||
byID[watchedRows[i].ID] = &watchedRows[i]
|
byID[watchedRows[i].ID] = &watchedRows[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近的已看集。
|
// 按最近观看顺序归并到「剧」维度,同时记住该剧最近看的那一集以及它是否看完。
|
||||||
type seriesState struct {
|
type seriesState struct {
|
||||||
key string
|
key string
|
||||||
current *model.Media
|
current *model.Media
|
||||||
|
completed bool
|
||||||
}
|
}
|
||||||
states := make([]seriesState, 0, len(histories))
|
states := make([]seriesState, 0, len(histories))
|
||||||
seen := make(map[string]bool, len(histories))
|
seen := make(map[string]bool, len(histories))
|
||||||
@@ -229,7 +230,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[key] = true
|
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 {
|
if len(states) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -293,7 +294,7 @@ func (s *MediaDiscoveryService) NextUpCandidates(ctx context.Context, userID str
|
|||||||
if len(out) >= limit {
|
if len(out) >= limit {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
next, ok := pickNextEpisode(bySeries[st.key], st.current, completed)
|
next, ok := pickNextEpisode(bySeries[st.key], st.current, st.completed, completed)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -341,16 +342,27 @@ func (s *MediaDiscoveryService) completedMediaIDs(ctx context.Context, userID st
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// pickNextEpisode 在候选集中选出严格晚于 current 的、编号最小的一集。
|
// pickNextEpisode 选出这部剧「接下来该看的那一集」。
|
||||||
// 比较顺序为 (季, 集),因此跨季时自然落到下一季第一集。
|
//
|
||||||
func pickNextEpisode(candidates []model.Media, current *model.Media, completed map[string]bool) (model.Media, bool) {
|
// 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
|
var best model.Media
|
||||||
found := false
|
found := false
|
||||||
for _, candidate := range candidates {
|
for _, candidate := range candidates {
|
||||||
if candidate.ID == current.ID || completed[candidate.ID] {
|
if candidate.ID == anchor.ID || completed[candidate.ID] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !episodeAfter(candidate, *current) {
|
if !episodeAfter(candidate, *anchor) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !found || episodeBefore(candidate, best) {
|
if !found || episodeBefore(candidate, best) {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
|
// seedEpisode 插入一集,并把播放历史指向 `watched`(nil 表示没有历史)。
|
||||||
|
//
|
||||||
|
// position_ms / duration_ms 只是占位值,NextUp 只看历史行的 completed 字段:
|
||||||
|
// 未看完的那一集本身就是「接下来该看的一集」。
|
||||||
func seedEpisode(
|
func seedEpisode(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
repos *repository.Container,
|
repos *repository.Container,
|
||||||
@@ -78,7 +81,7 @@ func TestNextUpPicksNextEpisode(t *testing.T) {
|
|||||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
watchedAt := time.Now().Add(-time.Hour)
|
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, 2, nil, false)
|
||||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, 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 接口负责。
|
// 电影不进 NextUp:NextUp 的语义是「下一集」,电影由 Resume 接口负责。
|
||||||
func TestNextUpSkipsMovies(t *testing.T) {
|
func TestNextUpSkipsMovies(t *testing.T) {
|
||||||
repos := newDiscoveryTestDB(t)
|
repos := newDiscoveryTestDB(t)
|
||||||
@@ -104,6 +141,10 @@ func TestNextUpSkipsMovies(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
|
// 同一部剧有多条未看完历史时,只能出一条,且指向最靠后的已看集的下一集。
|
||||||
|
// 同一部剧有多条未看完历史时只能出一条,且指向最近看过的那一集。
|
||||||
|
//
|
||||||
|
// 最近那一集(S1E2)本身还没看完,所以它就是「接下来该看的一集」;
|
||||||
|
// S1E1 只是更早的中间进度,不能据此跳到 S1E3。
|
||||||
func TestNextUpOneEntryPerSeries(t *testing.T) {
|
func TestNextUpOneEntryPerSeries(t *testing.T) {
|
||||||
repos := newDiscoveryTestDB(t)
|
repos := newDiscoveryTestDB(t)
|
||||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
@@ -116,8 +157,8 @@ func TestNextUpOneEntryPerSeries(t *testing.T) {
|
|||||||
|
|
||||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true})
|
||||||
if len(got) != 1 || got[0] != "S1E3" {
|
if len(got) != 1 || got[0] != "S1E2" {
|
||||||
t.Fatalf("next up = %v, want [S1E3]", got)
|
t.Fatalf("next up = %v, want [S1E2]", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,7 +168,7 @@ func TestNextUpCrossesSeason(t *testing.T) {
|
|||||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
watchedAt := time.Now().Add(-time.Hour)
|
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, 1, nil, false)
|
||||||
seedEpisode(t, repos, libID, "series-1", "剧一", 2, 2, 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")
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
watchedAt := time.Now().Add(-time.Hour)
|
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) // 已看完,下一集是 S1E3
|
||||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 2, nil, true) // 已看完
|
||||||
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
seedEpisode(t, repos, libID, "series-1", "剧一", 1, 3, nil, false)
|
||||||
|
|
||||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
@@ -155,13 +196,13 @@ func TestNextUpSkipsCompletedEpisode(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 追到最后一集时没有下一集,结果为空而不是重复返回最后一集。
|
// 追到最后一集且已看完时没有下一集,结果为空而不是重复返回最后一集。
|
||||||
func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
|
func TestNextUpEmptyAtSeriesEnd(t *testing.T) {
|
||||||
repos := newDiscoveryTestDB(t)
|
repos := newDiscoveryTestDB(t)
|
||||||
libID := seedDiscoveryLibrary(t, repos, "tv")
|
libID := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
watchedAt := time.Now().Add(-time.Hour)
|
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)
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
if got := nextUpIDs(t, svc, MediaVisibility{IncludeNSFW: true}); len(got) != 0 {
|
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")
|
hiddenLib := seedDiscoveryLibrary(t, repos, "tv")
|
||||||
watchedAt := time.Now().Add(-time.Hour)
|
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)
|
seedEpisode(t, repos, hiddenLib, "series-1", "剧一", 1, 2, nil, false)
|
||||||
|
|
||||||
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
svc := NewMediaDiscoveryService(zap.NewNop(), repos)
|
||||||
|
|||||||
Reference in New Issue
Block a user