diff --git a/internal/handler/media.go b/internal/handler/media.go index ef13e2f..2e9ae77 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -452,55 +452,114 @@ func updateMediaMetadataHandler(svc *service.Container) gin.HandlerFunc { } } +func paginateSlice[T any](items []T, page, size int) []T { + if page < 1 { + page = 1 + } + if size <= 0 { + size = 50 + } + if len(items) == 0 { + return []T{} + } + start := (page - 1) * size + if start >= len(items) { + return []T{} + } + end := start + size + if end > len(items) { + end = len(items) + } + return items[start:end] +} + func searchMediaHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { + ctx := c.Request.Context() q := c.Query("q") + visibility := mediaVisibilityForRequest(c, svc) groupVersions := c.DefaultQuery("group_versions", "1") != "0" + + fetchRemote := func(limit int) []model.Media { + if svc.EmbyRemote == nil || strings.TrimSpace(q) == "" { + return nil + } + remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility) + return remoteItems + } + if c.Query("page") != "" || c.Query("page_size") != "" { page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50")) if !groupVersions { - items, total, err := svc.Media.SearchMediaVisiblePage(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc)) + localItems, _, err := svc.Media.SearchMediaVisiblePage(ctx, q, 1, 50000, visibility) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + remoteItems := fetchRemote(size * 2) + all := append(localItems, remoteItems...) + paged := paginateSlice(all, page, size) c.JSON(http.StatusOK, gin.H{ - "items": items, - "total": total, + "items": paged, + "total": len(all), "page": page, "page_size": size, }) return } - items, total, err := svc.Media.SearchMediaVisiblePageGrouped(c.Request.Context(), q, page, size, mediaVisibilityForRequest(c, svc)) + localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + remoteItems := fetchRemote(size * 2) + all := append(localItems, remoteItems...) + grouped := service.GroupMediaVersions(all) + paged := service.PaginateMediaItems(grouped, page, size) c.JSON(http.StatusOK, gin.H{ - "items": items, - "total": total, + "items": paged, + "total": len(grouped), "page": page, "page_size": size, }) return } + limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50")) + if limit <= 0 { + limit = 50 + } if !groupVersions { - items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc)) + localItems, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"items": items}) + remoteItems := fetchRemote(limit) + all := append(localItems, remoteItems...) + if len(all) > limit { + all = all[:limit] + } + if all == nil { + all = []model.Media{} + } + c.JSON(http.StatusOK, gin.H{"items": all}) return } - items, err := svc.Media.SearchMediaVisibleGrouped(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc)) + + localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + remoteItems := fetchRemote(limit) + all := append(localItems, remoteItems...) + grouped := service.GroupMediaVersions(all) + items := service.FirstMediaItems(grouped, limit) + if items == nil { + items = []service.MediaItem{} + } c.JSON(http.StatusOK, gin.H{"items": items}) } } diff --git a/internal/handler/media_test.go b/internal/handler/media_test.go index 410d941..032c119 100644 --- a/internal/handler/media_test.go +++ b/internal/handler/media_test.go @@ -436,8 +436,180 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) { if strings.Contains(body, `"items":null`) { t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body) } - if !strings.Contains(body, `"items":[]`) { - t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body) + if !strings.Contains(body, `"items":[]`) { + t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body) + } + } + } + +func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) { + gin.SetMode(gin.TestMode) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("SearchTerm") == "碧蓝之海" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 1, + "Items": []map[string]any{ + { + "Id": "156030", + "Name": "碧蓝之海", + "Type": "Series", + "ProductionYear": 2018, + }, + }, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 0, + "Items": []map[string]any{}, + }) + })) + defer server.Close() + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil { + t.Fatal(err) + } + repos := repository.New(db) + adminUser := model.User{ + Base: model.Base{ID: "user-1"}, + Username: "admin", + Role: "admin", + } + _ = repos.DB.Create(&adminUser).Error + + localLib := model.Library{Name: "本地电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &localLib); err != nil { + t.Fatal(err) + } + localMedia := model.Media{ + Base: model.Base{ID: "local-1"}, + LibraryID: localLib.ID, + Title: "流浪地球", + Year: 2019, + } + if err := repos.DB.Create(&localMedia).Error; err != nil { + t.Fatal(err) + } + + rawCfg, _ := json.Marshal(map[string]string{"url": server.URL, "token": "fake-token"}) + acct := model.StrmAccount{ + Base: model.Base{ID: "acct-1"}, + Name: "远程Emby", + Provider: model.StrmProviderEmbyRemote, + Config: string(rawCfg), + Enabled: true, + } + if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil { + t.Fatal(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.Fatal(err) + } + + crypto := service.NewCryptoService("", zap.NewNop()) + remoteSvc := service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, crypto) + mediaSvc := service.NewMediaService(&config.Config{}, zap.NewNop(), repos) + + svc := &service.Container{ + Repo: repos, + Media: mediaSvc, + EmbyRemote: remoteSvc, + } + + // 1. 搜索远程挂载媒体(碧蓝之海) + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, "user-1") + c.Set(middleware.CtxUserRole, "admin") + c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=碧蓝之海&limit=8", nil) + searchMediaHandler(svc)(c) + + if w.Code != http.StatusOK { + t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String()) + } + var res struct { + Items []service.MediaItem `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if len(res.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(res.Items)) + } + if res.Items[0].Title != "碧蓝之海" { + t.Fatalf("expected Title '碧蓝之海', got %q", res.Items[0].Title) + } + expectedID := service.EncodeEmbyRemoteID("mount-1", "156030") + if res.Items[0].ID != expectedID { + t.Fatalf("expected ID %q, got %q", expectedID, res.Items[0].ID) + } + } + + // 2. 搜索本地媒体(流浪地球) + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, "user-1") + c.Set(middleware.CtxUserRole, "admin") + c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=流浪地球&limit=8", nil) + searchMediaHandler(svc)(c) + + if w.Code != http.StatusOK { + t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String()) + } + var res struct { + Items []service.MediaItem `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if len(res.Items) != 1 { + t.Fatalf("expected 1 item, got %d", len(res.Items)) + } + if res.Items[0].Title != "流浪地球" { + t.Fatalf("expected Title '流浪地球', got %q", res.Items[0].Title) + } + } + + // 3. 搜索不存在的媒体 + { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, "user-1") + c.Set(middleware.CtxUserRole, "admin") + c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=不存在的影片&limit=8", nil) + searchMediaHandler(svc)(c) + + if w.Code != http.StatusOK { + t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String()) + } + var res struct { + Items []service.MediaItem `json:"items"` + } + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatal(err) + } + if len(res.Items) != 0 { + t.Fatalf("expected 0 items, got %d", len(res.Items)) + } + if strings.Contains(w.Body.String(), `"items":null`) { + t.Fatalf("expected items:[], got null: %s", w.Body.String()) } } } diff --git a/internal/handler/search_extra.go b/internal/handler/search_extra.go index 439adb0..14eb4c1 100644 --- a/internal/handler/search_extra.go +++ b/internal/handler/search_extra.go @@ -7,6 +7,7 @@ package handler import ( "net/http" "strconv" + "strings" "github.com/gin-gonic/gin" @@ -16,16 +17,26 @@ import ( // searchUnifiedHandler is the basic /api/search endpoint. func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { + ctx := c.Request.Context() q := c.Query("q") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) if limit <= 0 || limit > 200 { limit = 30 } - items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc)) + visibility := mediaVisibilityForRequest(c, svc) + items, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if svc.EmbyRemote != nil && strings.TrimSpace(q) != "" { + if remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility); len(remoteItems) > 0 { + items = append(items, remoteItems...) + if len(items) > limit { + items = items[:limit] + } + } + } c.JSON(http.StatusOK, gin.H{"items": items, "total": len(items)}) } } @@ -36,16 +47,26 @@ func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc { // them back. This keeps API parity without a giant query builder. func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { + ctx := c.Request.Context() q := c.Query("q") limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30")) if limit <= 0 || limit > 200 { limit = 30 } - items, err := svc.Media.SearchMediaVisible(c.Request.Context(), q, limit, mediaVisibilityForRequest(c, svc)) + visibility := mediaVisibilityForRequest(c, svc) + items, err := svc.Media.SearchMediaVisible(ctx, q, limit, visibility) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } + if svc.EmbyRemote != nil && strings.TrimSpace(q) != "" { + if remoteItems, _ := svc.EmbyRemote.RemoteSearchMedia(ctx, q, limit, visibility); len(remoteItems) > 0 { + items = append(items, remoteItems...) + if len(items) > limit { + items = items[:limit] + } + } + } c.JSON(http.StatusOK, gin.H{ "items": items, "filters": gin.H{ diff --git a/internal/service/crypto.go b/internal/service/crypto.go index 4832f9c..1269186 100644 --- a/internal/service/crypto.go +++ b/internal/service/crypto.go @@ -77,7 +77,7 @@ func (c *CryptoService) Encrypt(plain string) string { // Decrypt returns the plaintext for an encrypted value. Plaintext rows // (no prefix) are returned unchanged. func (c *CryptoService) Decrypt(value string) string { - if value == "" || c.aead == nil { + if c == nil || value == "" || c.aead == nil { return value } if !strings.HasPrefix(value, encPrefix) { diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index f1da252..d1334da 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -502,10 +503,136 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model. m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it) cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0}) } - if r.cache != nil { - r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + if r.cache != nil { + r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL()) + } + return cards, nil } - return cards, nil + +// RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series), +// 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。 +func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) { + if r == nil { + return nil, nil + } + query = strings.TrimSpace(query) + if query == "" { + return nil, nil + } + if limit <= 0 { + limit = 50 + } else if limit > maxMediaSearchLimit { + limit = maxMediaSearchLimit + } + + mounts, err := r.ListMounts(ctx) + if err != nil || len(mounts) == 0 { + return nil, err + } + + type mountTarget struct { + mount model.EmbyMount + acct *model.StrmAccount + cfg *EmbyRemoteConfig + } + var targets []mountTarget + for _, m := range mounts { + if !m.Enabled { + continue + } + libID := EncodeEmbyRemoteID(m.ID, m.RemoteViewID) + hidden := false + for _, hid := range visibility.HiddenLibraryIDs { + if hid == libID { + hidden = true + break + } + } + if hidden { + continue + } + if len(visibility.AllowedLibraryIDs) > 0 { + allowed := false + for _, aid := range visibility.AllowedLibraryIDs { + if aid == libID { + allowed = true + break + } + } + if !allowed { + continue + } + } + + acct := r.AccountByID(ctx, m.AccountID) + if acct == nil { + continue + } + cfg, cfgErr := r.configOf(acct) + if cfgErr != nil { + continue + } + targets = append(targets, mountTarget{mount: m, acct: acct, cfg: cfg}) + } + if len(targets) == 0 { + return nil, nil + } + + searchCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + sem := make(chan struct{}, 6) + var wg sync.WaitGroup + type searchResult struct { + items []model.Media + } + results := make([]searchResult, len(targets)) + for i, t := range targets { + wg.Add(1) + go func(idx int, target mountTarget) { + defer wg.Done() + select { + case sem <- struct{}{}: + defer func() { <-sem }() + case <-searchCtx.Done(): + return + } + + q := url.Values{} + q.Set("ParentId", target.mount.RemoteViewID) + q.Set("Recursive", "true") + q.Set("SearchTerm", query) + q.Set("IncludeItemTypes", "Movie,Series") + q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating") + q.Set("Limit", strconv.Itoa(limit)) + q.Set("StartIndex", "0") + + var body struct { + Items []map[string]any `json:"Items"` + } + if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil { + if r.log != nil { + r.log.Warn("remote search failed", + zap.String("mount", target.mount.RemoteViewName), zap.Error(err)) + } + return + } + medias := make([]model.Media, 0, len(body.Items)) + for _, it := range body.Items { + RewriteEmbyRemoteIDs(it, target.mount.ID) + m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it) + medias = append(medias, m) + } + results[idx] = searchResult{items: medias} + }(i, t) + } + wg.Wait() + + var out []model.Media + for _, res := range results { + out = append(out, res.items...) + } + return out, nil } // WebStreamURL 远程条目的网页播放地址(302 直连远程 Emby 流端点)。 diff --git a/internal/service/emby_remote_web_test.go b/internal/service/emby_remote_web_test.go index 51579ec..8886825 100644 --- a/internal/service/emby_remote_web_test.go +++ b/internal/service/emby_remote_web_test.go @@ -2,10 +2,17 @@ package service import ( "context" + "encoding/json" + "net/http" + "net/http/httptest" "testing" "time" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" ) func TestMapRemoteItemToMediaSortingFields(t *testing.T) { @@ -61,7 +68,116 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) { if media.Rating != 9.2 { t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating) } - if media.Year != 2022 { - t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) + if media.Year != 2022 { + t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year) + } + } + +func TestRemoteSearchMedia(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if q.Get("SearchTerm") == "碧蓝之海" { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 1, + "Items": []map[string]any{ + { + "Id": "156030", + "Name": "碧蓝之海", + "Type": "Series", + "ProductionYear": 2018, + }, + }, + }) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 0, + "Items": []map[string]any{}, + }) + })) + 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", + }) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-1"}, + Name: "test-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) + } + + // 1. 正常搜索 + items, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatalf("RemoteSearchMedia failed: %v", err) + } + if len(items) != 1 { + t.Fatalf("expected 1 item, got %d", len(items)) + } + if items[0].Title != "碧蓝之海" { + t.Fatalf("expected Title '碧蓝之海', got %q", items[0].Title) + } + expectedID := EncodeEmbyRemoteID("mount-1", "156030") + if items[0].ID != expectedID { + t.Fatalf("expected ID %q, got %q", expectedID, items[0].ID) + } + + // 2. 搜索不到的内容 + notFound, err := svc.RemoteSearchMedia(t.Context(), "其它不存在的剧", 10, MediaVisibility{IncludeNSFW: true}) + if err != nil { + t.Fatalf("RemoteSearchMedia failed: %v", err) + } + if len(notFound) != 0 { + t.Fatalf("expected 0 items, got %d", len(notFound)) + } + + // 3. 白名单过滤:当白名单不包含该挂载虚拟库 ID 时应过滤掉 + allowedLibID := "local-lib-1" + filtered, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{ + IncludeNSFW: true, + AllowedLibraryIDs: []string{allowedLibID}, + }) + if err != nil { + t.Fatalf("RemoteSearchMedia with allowed filter failed: %v", err) + } + if len(filtered) != 0 { + t.Fatalf("expected 0 items due to AllowedLibraryIDs, got %d", len(filtered)) + } + + // 4. 黑名单过滤:当黑名单包含该挂载虚拟库 ID 时应过滤掉 + mountLibID := EncodeEmbyRemoteID("mount-1", "view-1") + hiddenFiltered, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{ + IncludeNSFW: true, + HiddenLibraryIDs: []string{mountLibID}, + }) + if err != nil { + t.Fatalf("RemoteSearchMedia with hidden filter failed: %v", err) + } + if len(hiddenFiltered) != 0 { + t.Fatalf("expected 0 items due to HiddenLibraryIDs, got %d", len(hiddenFiltered)) } } diff --git a/internal/service/media_versions.go b/internal/service/media_versions.go index e2bbc9e..92997ac 100644 --- a/internal/service/media_versions.go +++ b/internal/service/media_versions.go @@ -44,6 +44,11 @@ func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem { return items[start:end] } +// PaginateMediaItems 导出分页辅助函数。 +func PaginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem { + return paginateMediaItems(items, page, pageSize) +} + func firstMediaItems(items []MediaItem, limit int) []MediaItem { if len(items) == 0 { return nil @@ -60,6 +65,11 @@ func firstMediaItems(items []MediaItem, limit int) []MediaItem { return items[:limit] } +// FirstMediaItems 导出截取前 N 项辅助函数。 +func FirstMediaItems(items []MediaItem, limit int) []MediaItem { + return firstMediaItems(items, limit) +} + func groupMediaVersions(items []model.Media) []MediaItem { if len(items) == 0 { return nil @@ -104,6 +114,11 @@ func groupMediaVersions(items []model.Media) []MediaItem { return out } +// GroupMediaVersions 导出多版本分组函数。 +func GroupMediaVersions(items []model.Media) []MediaItem { + return groupMediaVersions(items) +} + func mediaVersionGroupKey(m model.Media) string { if m.SeasonNum > 0 || m.EpisodeNum > 0 { switch {