diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 27e4af7..0b82e7b 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -880,6 +880,9 @@ func (r *EmbyRemoteService) RemoteLatestForDisplay(ctx context.Context, mount *m // RemoteLatestSeries 拉取剧集库最近更新的 Series。部分 Emby 服务不支持 // DateLastContentAdded 或过滤 Series,此时回退到 Latest 并把 Episode 归并到 // 对应 Series。 +// +// 使用 Recursive=true 并跳过 anime/ 等中间容器,与 RemoteSeriesCards / +// Emby 客户端列剧集方式一致。 func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]map[string]any, error) { if limit <= 0 || limit > 100 { limit = 20 @@ -891,17 +894,32 @@ func (r *EmbyRemoteService) RemoteLatestSeries(ctx context.Context, mount *model q := url.Values{} q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", "Series") - q.Set("Recursive", "false") + q.Set("Recursive", "true") q.Set("SortBy", "DateLastContentAdded") q.Set("SortOrder", "Descending") - q.Set("Limit", strconv.Itoa(limit)) + // 多取一些以便滤掉中间容器后仍够 limit。 + q.Set("Limit", strconv.Itoa(limit*4)) q.Set("Fields", "Overview,Genres,ProviderIds,Path,RecursiveItemCount,SeriesPrimaryImage,DateCreated,DateLastMediaAdded,PremiereDate,ProductionYear,CommunityRating,CriticRating") var body struct { Items []map[string]any `json:"Items"` } if err := r.doGet(ctx, acct, cfg, "/Users/"+url.PathEscape(r.remoteUserID(cfg))+"/Items", q, &body); err == nil && len(body.Items) > 0 { - RewriteEmbyRemoteIDs(body.Items, mount.ID) - return body.Items, nil + filtered := make([]map[string]any, 0, limit) + for _, it := range body.Items { + name := remoteItemString(it, "Name") + path := remoteItemString(it, "Path") + if remoteSeriesItemLooksLikeContainer(name, path) { + continue + } + filtered = append(filtered, it) + if len(filtered) >= limit { + break + } + } + if len(filtered) > 0 { + RewriteEmbyRemoteIDs(filtered, mount.ID) + return filtered, nil + } } items, err := r.RemoteLatest(ctx, mount, acct, remoteViewID, limit) diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index e6f4ef7..35e486f 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -516,17 +516,17 @@ const remoteSeriesPageSize = 200 // RemoteSeriesCards 远程剧集库的系列卡片(ChildCount 作为集数)。 // -// 远程 Emby 的 Series DTO 不会返回 DateLastMediaAdded 字段(即使请求 Fields -// 也缺失),但其服务端排序支持 SortBy=DateLastContentAdded——即客户端"上次 -// 添加集日期"排序。因此这里直接按该键倒序分页拉全量,返回的卡片顺序与对方 -// Emby 客户端选择"上次添加集日期"完全一致;LastAddedAt 在远程提供字段时 -// 才填充,否则保持 nil(前端对无该值的卡片维持服务器顺序,不再回退加入日期)。 +// 与 Emby 客户端一致:IncludeItemTypes=Series + Recursive=true,按 +// DateLastContentAdded 倒序分页拉全库剧集。多媒体根下的 anime/ 等中间容器 +// 若偶发出现在结果里则过滤掉;LastAddedAt 仅在远程提供 DateLastMediaAdded +// 时填充。 func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string) ([]SeriesCard, error) { cfg, err := r.remoteConfigWithToken(ctx, acct) if err != nil { return nil, err } - cacheKey := r.remoteCacheKey("series-cards", acct.ID, mount.ID, remoteViewID) + // v3:Recursive=true(对齐 Emby 客户端),与旧直属/下探缓存区分。 + cacheKey := r.remoteCacheKey("series-cards-v3", acct.ID, mount.ID, remoteViewID) var cached []SeriesCard if r.cache != nil && r.cache.GetJSON(ctx, cacheKey, &cached) { return cached, nil @@ -534,7 +534,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. q := url.Values{} q.Set("ParentId", remoteViewID) q.Set("IncludeItemTypes", "Series") - q.Set("Recursive", "false") + q.Set("Recursive", "true") q.Set("SortBy", "DateLastContentAdded") q.Set("SortOrder", "Descending") q.Set("Limit", strconv.Itoa(remoteSeriesPageSize)) @@ -554,9 +554,13 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. break } for _, it := range body.Items { + name := remoteItemString(it, "Name") + path := remoteItemString(it, "Path") + if remoteSeriesItemLooksLikeContainer(name, path) { + continue + } RewriteEmbyRemoteIDs(it, mount.ID) m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) - // 集数优先用递归条目数(ChildCount 只算直属 Season 文件夹数)。 count := remoteItemInt(it, "RecursiveItemCount") if count == 0 { count = remoteItemInt(it, "ChildCount") @@ -577,7 +581,7 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. LastAddedAt: lastAdded, }) } - if int64(len(cards)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize { + if int64(startIndex+len(body.Items)) >= body.TotalRecordCount || len(body.Items) < remoteSeriesPageSize { break } } @@ -587,6 +591,14 @@ func (r *EmbyRemoteService) RemoteSeriesCards(ctx context.Context, mount *model. return cards, nil } +func remoteSeriesItemLooksLikeContainer(name, path string) bool { + if isEmbyGenericContainer(name) { + return true + } + base := pathBaseSlash(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/")) + return base != "" && isEmbyGenericContainer(base) +} + // RemoteLatestCards 远程库最新条目(首页预览卡片),映射 SeriesCard。 func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.EmbyMount, acct *model.StrmAccount, remoteViewID string, limit int) ([]SeriesCard, error) { cfg, err := r.remoteConfigWithToken(ctx, acct) diff --git a/internal/service/emby_remote_web_test.go b/internal/service/emby_remote_web_test.go index a2857e4..6db788c 100644 --- a/internal/service/emby_remote_web_test.go +++ b/internal/service/emby_remote_web_test.go @@ -121,10 +121,15 @@ func TestRemoteSeriesCardsAutoAuthOnFirstBrowse(t *testing.T) { } if r.URL.Path == "/emby/Users/real-user-guid/Items" { q := r.URL.Query() - if q.Get("ParentId") != "view-1" || q.Get("IncludeItemTypes") != "Series" { + if q.Get("IncludeItemTypes") != "Series" || q.Get("ParentId") != "view-1" { w.WriteHeader(http.StatusBadRequest) return } + if q.Get("Recursive") != "true" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("expected Recursive=true")) + return + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "TotalRecordCount": 1, @@ -231,6 +236,11 @@ func TestRemoteSeriesCardsResolveUserIDFromAPIKey(t *testing.T) { return } if r.URL.Path == "/emby/Users/real-user-guid/Items" { + q := r.URL.Query() + if q.Get("ParentId") != "view-2" || q.Get("Recursive") != "true" { + w.WriteHeader(http.StatusNotFound) + return + } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(map[string]any{ "TotalRecordCount": 1, @@ -674,6 +684,11 @@ func TestRemoteSeriesCardsFetchAllPages(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") query := r.URL.Query() + if query.Get("Recursive") != "true" { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte("expected Recursive=true")) + return + } startIndex, _ := strconv.Atoi(query.Get("StartIndex")) limit, _ := strconv.Atoi(query.Get("Limit")) requests.Add(1) @@ -740,6 +755,84 @@ func TestRemoteSeriesCardsFetchAllPages(t *testing.T) { } } +// 与 Emby 客户端一致:Recursive=true 拉全库 Series,并过滤 anime 等中间容器。 +func TestRemoteSeriesCardsRecursiveFiltersAnimeContainers(t *testing.T) { + var sawRecursive atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if strings.TrimPrefix(r.URL.Path, "/emby") != "/Users/remote-user/Items" { + http.NotFound(w, r) + return + } + q := r.URL.Query() + if q.Get("ParentId") != "view-2023" || q.Get("IncludeItemTypes") != "Series" { + http.NotFound(w, r) + return + } + if q.Get("Recursive") == "true" { + sawRecursive.Store(true) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 5, + "Items": []map[string]any{ + {"Id": "c0", "Name": "anime", "Type": "Series", "Path": "https://cdn/0/anime/", "RecursiveItemCount": 3086}, + {"Id": "c1", "Name": "anime", "Type": "Series", "Path": "https://cdn/1/anime/", "RecursiveItemCount": 567}, + {"Id": "s-a", "Name": "数码宝贝 BEATBREAK", "Type": "Series", "Path": "https://cdn/0/anime/digimon", "RecursiveItemCount": 24}, + {"Id": "s-b", "Name": "活死喵之夜", "Type": "Series", "Path": "https://cdn/0/anime/nyaight", "RecursiveItemCount": 12}, + {"Id": "s-c", "Name": "药屋少女的呢喃", "Type": "Series", "Path": "https://cdn/1/anime/kusuriya", "RecursiveItemCount": 51}, + }, + }) + })) + defer server.Close() + + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}) + repos := repository.New(db) + svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + + rawConfig, _ := json.Marshal(map[string]string{ + "url": server.URL, + "token": "fake-token", + "remote_user_id": "remote-user", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-recursive"}, + Name: "nijigem", + 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-recursive"}, + AccountID: acct.ID, + RemoteViewID: "view-2023", + RemoteViewName: "2023前 动漫", + CollectionType: "tvshows", + Enabled: true, + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatalf("create mount: %v", err) + } + + cards, err := svc.RemoteSeriesCards(t.Context(), mount, acct, mount.RemoteViewID) + if err != nil { + t.Fatalf("RemoteSeriesCards failed: %v", err) + } + if !sawRecursive.Load() { + t.Fatal("expected Recursive=true on remote Items request") + } + if len(cards) != 3 { + t.Fatalf("cards = %d, want 3 real series after filtering anime containers", len(cards)) + } + for _, card := range cards { + if strings.EqualFold(card.Rep.Title, "anime") { + t.Fatalf("container title %q should have been filtered", card.Rep.Title) + } + } +} + // 首页「最新条目」卡片 key 必须与媒体库剧集列表的 key 一致,否则点击后 // 媒体库页找不到目标剧集,只能退回整库列表。 func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) { @@ -751,6 +844,14 @@ func TestRemoteLatestCardsKeyMatchesSeriesList(t *testing.T) { return } seriesQueryCalls.Add(1) + parentID := r.URL.Query().Get("ParentId") + if parentID == "series-latest" { + _ = json.NewEncoder(w).Encode(map[string]any{ + "Items": []map[string]any{}, + "TotalRecordCount": 0, + }) + return + } _ = json.NewEncoder(w).Encode(map[string]any{ "Items": []map[string]any{ {