diff --git a/internal/handler/emby_discovery_test.go b/internal/handler/emby_discovery_test.go index e62bb5b..3c3d518 100644 --- a/internal/handler/emby_discovery_test.go +++ b/internal/handler/emby_discovery_test.go @@ -105,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) @@ -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。 func TestEmbyNextUpEmptyWithoutHistory(t *testing.T) { router, _, _ := newEmbyDiscoveryEnv(t) @@ -267,8 +310,8 @@ func TestEmbyNextUpFiltersBySeriesID(t *testing.T) { case 1: watchedID = m.ID 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 集已看完,下一集是 S1E2 } if err := svc.Repo.DB.Create(h).Error; err != nil { t.Fatal(err) diff --git a/internal/service/emby_discovery.go b/internal/service/emby_discovery.go index 103dcc4..b0dbdf3 100644 --- a/internal/service/emby_discovery.go +++ b/internal/service/emby_discovery.go @@ -143,9 +143,11 @@ func (e *EmbyService) nextUpForRemoteSeries(ctx context.Context, userID, seriesI } 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 } } @@ -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 { return emptyItemsEnvelope(0), nil } diff --git a/internal/service/emby_remote_nextup_test.go b/internal/service/emby_remote_nextup_test.go new file mode 100644 index 0000000..3826fbf --- /dev/null +++ b/internal/service/emby_remote_nextup_test.go @@ -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) + } +} diff --git a/internal/service/media_discovery.go b/internal/service/media_discovery.go index 6c8db0a..743ad20 100644 --- a/internal/service/media_discovery.go +++ b/internal/service/media_discovery.go @@ -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) { diff --git a/internal/service/media_discovery_nextup_test.go b/internal/service/media_discovery_nextup_test.go index 0a164c1..abeba35 100644 --- a/internal/service/media_discovery_nextup_test.go +++ b/internal/service/media_discovery_nextup_test.go @@ -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)