From e2cd32af5332dc2ce74f2c3b5eb9c02d8e141ade Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:31:09 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A4=84=E7=90=86yamby=E7=BB=A7=E7=BB=AD?= =?UTF-8?q?=E6=92=AD=E6=94=BEbug?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/handler/emby_discovery.go | 37 +++++- internal/handler/emby_discovery_test.go | 85 +++++++++++++ internal/handler/emby_items_handlers.go | 5 +- internal/handler/emby_routes.go | 3 + internal/handler/emby_routes_lowercase.go | 2 + internal/service/emby_discovery.go | 142 +++++++++++++++++++++- internal/service/emby_items_detail.go | 36 +++++- 7 files changed, 304 insertions(+), 6 deletions(-) diff --git a/internal/handler/emby_discovery.go b/internal/handler/emby_discovery.go index 1c84f76..de3399b 100644 --- a/internal/handler/emby_discovery.go +++ b/internal/handler/emby_discovery.go @@ -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 diff --git a/internal/handler/emby_discovery_test.go b/internal/handler/emby_discovery_test.go index 16d34b6..e62bb5b 100644 --- a/internal/handler/emby_discovery_test.go +++ b/internal/handler/emby_discovery_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strconv" "testing" "time" @@ -242,3 +243,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: 1000, DurationMs: 2000, + WatchedAt: watchedAt, Completed: false, + } + 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) + } +} diff --git a/internal/handler/emby_items_handlers.go b/internal/handler/emby_items_handlers.go index 4152a00..da74922 100644 --- a/internal/handler/emby_items_handlers.go +++ b/internal/handler/emby_items_handlers.go @@ -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 diff --git a/internal/handler/emby_routes.go b/internal/handler/emby_routes.go index 4adc4a7..24080f3 100644 --- a/internal/handler/emby_routes.go +++ b/internal/handler/emby_routes.go @@ -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)) diff --git a/internal/handler/emby_routes_lowercase.go b/internal/handler/emby_routes_lowercase.go index afc5f70..7c780a1 100644 --- a/internal/handler/emby_routes_lowercase.go +++ b/internal/handler/emby_routes_lowercase.go @@ -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)) diff --git a/internal/service/emby_discovery.go b/internal/service/emby_discovery.go index 296e232..103dcc4 100644 --- a/internal/service/emby_discovery.go +++ b/internal/service/emby_discovery.go @@ -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,138 @@ 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 + for i := range hist { + if m := epByID[hist[i].MediaID]; m != nil { + current = m + 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, 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 返回与指定条目相似的本地媒体。 // // 找不到条目(或该条目对当前用户不可见)时返回空列表而不是错误:客户端会在 diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 6748dcd..3fbf7ef 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -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++