diff --git a/internal/handler/media_search_test.go b/internal/handler/media_search_test.go new file mode 100644 index 0000000..a9b3591 --- /dev/null +++ b/internal/handler/media_search_test.go @@ -0,0 +1,111 @@ +package handler + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/middleware" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func TestSearchMediaSeriesKeepsRemoteLibrariesSeparate(t *testing.T) { + gin.SetMode(gin.TestMode) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "TotalRecordCount": 1, + "Items": []map[string]any{{ + "Id": "same-series", "Name": "龙珠改", "Type": "Series", + "ProviderIds": map[string]string{"Tmdb": "61709"}, + }}, + }) + })) + defer upstream.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) + lib := model.Library{Name: "本地番剧", Path: "/media/anime", Type: "anime", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatal(err) + } + rows := []model.Media{ + {Base: model.Base{ID: "local-1"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709}, + {Base: model.Base{ID: "local-2"}, LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatal(err) + } + rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"}) + acct := model.StrmAccount{Base: model.Base{ID: "account"}, Name: "Emby", Provider: model.StrmProviderEmbyRemote, Config: string(rawConfig), Enabled: true} + if err := repos.StrmAccount.Create(t.Context(), &acct); err != nil { + t.Fatal(err) + } + expectedIDs := map[string]string{lib.ID: "local-1"} + for _, suffix := range []string{"a", "b"} { + mount := model.EmbyMount{Base: model.Base{ID: "mount-" + suffix}, AccountID: acct.ID, RemoteViewID: "view-" + suffix, RemoteViewName: "番剧" + suffix, CollectionType: "tvshows", Enabled: true} + if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil { + t.Fatal(err) + } + expectedIDs[service.EncodeEmbyRemoteID(mount.ID, mount.RemoteViewID)] = service.EncodeEmbyRemoteID(mount.ID, "same-series") + } + svc := &service.Container{ + Repo: repos, + Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos), + EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())), + } + + request := func(params string) ([]model.Media, int) { + t.Helper() + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set(middleware.CtxUserID, "admin") + c.Set(middleware.CtxUserRole, "admin") + c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠改&group_series=1&"+params, nil) + searchMediaHandler(svc)(c) + if w.Code != http.StatusOK { + t.Fatalf("search status=%d, body=%s", w.Code, w.Body.String()) + } + var response struct { + Items []model.Media `json:"items"` + Total int `json:"total"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + return response.Items, response.Total + } + + items, _ := request("limit=8") + if len(items) != len(expectedIDs) { + t.Fatalf("got %d results, want one local series and two remote series: %#v", len(items), items) + } + seen := make(map[string]bool) + for page := 1; page <= len(expectedIDs); page++ { + items, total := request(fmt.Sprintf("page=%d&page_size=1", page)) + if total != len(expectedIDs) || len(items) != 1 { + t.Fatalf("page %d: total=%d items=%#v, want total=3 and one item", page, total, items) + } + item := items[0] + if expectedIDs[item.DisplayLibraryID] != item.ID || seen[item.DisplayLibraryID] || item.DisplayLibraryName == "" { + t.Fatalf("page %d lost or repeated a library's series identity: %#v", page, item) + } + seen[item.DisplayLibraryID] = true + } +} diff --git a/internal/service/media_search_test.go b/internal/service/media_search_test.go new file mode 100644 index 0000000..78af0a4 --- /dev/null +++ b/internal/service/media_search_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "strings" + "testing" + + "github.com/truewhile/MeBox/internal/model" +) + +func TestGroupMediaSeriesItemsKeepsLibrariesSeparate(t *testing.T) { + for _, tc := range []struct { + name string + media model.Media + }{ + {"remote series with TMDb ID", model.Media{TMDbID: 61709}}, + {"remote series with Bangumi ID", model.Media{BangumiID: 61709}}, + {"flat episode with TMDb ID", model.Media{SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709}}, + {"flat episode with Bangumi ID", model.Media{SeasonNum: 1, EpisodeNum: 1, BangumiID: 61709}}, + {"flat episode with Douban ID", model.Media{SeasonNum: 1, EpisodeNum: 1, DoubanID: "61709"}}, + {"flat episode with TVDB ID", model.Media{SeasonNum: 1, EpisodeNum: 1, TheTVDBID: "61709"}}, + {"shared series ID", model.Media{SeriesID: "same-series"}}, + {"same title and directory", model.Media{Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1}}, + } { + t.Run(tc.name, func(t *testing.T) { + first, second := tc.media, tc.media + first.ID, first.LibraryID, first.LibraryName, first.Title = "first", "anime-a", "番剧库甲", "龙珠改" + second.ID, second.LibraryID, second.LibraryName, second.Title = "second", "anime-b", "番剧库乙", "龙珠改" + if strings.HasPrefix(tc.name, "remote") { + first.ID = EncodeEmbyRemoteID("mount-a", "same-id") + second.ID = EncodeEmbyRemoteID("mount-b", "same-id") + } + + items := GroupMediaSeriesItems([]model.Media{first, second}) + if len(items) != 2 { + t.Fatalf("got %d search results, want one per library: %#v", len(items), items) + } + seen := make(map[string]bool) + for _, item := range items { + seen[item.LibraryID] = true + if item.Title != "龙珠改" || item.LibraryName == "" { + t.Fatalf("search result lost title or library metadata: %#v", item) + } + } + if !seen[first.LibraryID] || !seen[second.LibraryID] { + t.Fatalf("search results lost a library: %#v", items) + } + }) + } +} + +func TestGroupMediaSeriesItemsFoldsEpisodesWithinDisplayLibrary(t *testing.T) { + items := []model.Media{ + {Base: model.Base{ID: "a-2"}, LibraryID: "source-a", DisplayLibraryID: "anime-a", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709}, + {Base: model.Base{ID: "a-1"}, LibraryID: "source-b", DisplayLibraryID: "anime-a", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709}, + {Base: model.Base{ID: "b-2"}, LibraryID: "source-a", DisplayLibraryID: "anime-b", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep2.mkv", SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709}, + {Base: model.Base{ID: "b-1"}, LibraryID: "source-a", DisplayLibraryID: "anime-b", Title: "龙珠改", Path: "/media/anime/龙珠改/Season 1/ep1.mkv", SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709}, + } + grouped := GroupMediaSeriesItems(items) + if len(grouped) != 2 { + t.Fatalf("got %d results, want one series per display library: %#v", len(grouped), grouped) + } + seen := make(map[string]bool) + for _, item := range grouped { + if item.EpisodeNum != 1 || seen[item.DisplayLibraryID] { + t.Fatalf("expected the first episode from each display library: %#v", grouped) + } + seen[item.DisplayLibraryID] = true + } +} diff --git a/internal/service/media_series.go b/internal/service/media_series.go index f976100..961dad2 100644 --- a/internal/service/media_series.go +++ b/internal/service/media_series.go @@ -524,9 +524,16 @@ func groupMediaSeriesCardsByKeys(items []model.Media, keys []string) []SeriesCar // GroupMediaSeriesItems folds episode-level rows into one representative media // row per series. It is intended for search surfaces where applying a small // limit before series grouping would otherwise return several episodes from -// the same show. +// the same show. Each display library keeps its own search result. func GroupMediaSeriesItems(items []model.Media) []model.Media { - cards := groupMediaSeriesCards(items) + _, keys := resolveMediaSeriesKeys(items) + for i, item := range items { + // External IDs and SeriesID can be shared by different libraries. Scope + // search grouping to the same library used by the result's label/link, + // while preserving episode folding within merged display libraries. + keys[i] = seriesFingerprint(mediaTargetLibraryID(item), keys[i]) + } + cards := groupMediaSeriesCardsByKeys(items, keys) if len(cards) == 0 { return []model.Media{} } diff --git a/web/src/components/MediaCard.tsx b/web/src/components/MediaCard.tsx index b32e395..ee52b68 100644 --- a/web/src/components/MediaCard.tsx +++ b/web/src/components/MediaCard.tsx @@ -10,7 +10,7 @@ const ACTION_OVERLAY_CLASS = // memo:父级状态变化(如轮播切图、其它卡片操作)不再级联重渲染所有卡片。 // 注意 actions/renderActions 必须引用稳定(用 renderActions 传函数)memo 才生效。 export const MediaCard = memo(function MediaCard({ - media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact, + media, progress, count, rating, linkTo, linkState, onClick, actions, renderActions, compact, showLibrary, }: { media: Media progress?: number @@ -22,6 +22,7 @@ export const MediaCard = memo(function MediaCard({ actions?: ReactNode renderActions?: (media: Media) => ReactNode compact?: boolean + showLibrary?: boolean }) { const ref = useRef(null) const href = linkTo ?? `/media/${media.id}` @@ -31,6 +32,7 @@ export const MediaCard = memo(function MediaCard({ const blurredPosterSrc = imageURL(media.poster_url, media.updated_at, ARTWORK.posterTiny) const displayRating = rating ?? media.rating const versionCount = media.versions?.length ?? 0 + const libraryName = media.display_library_name || media.library_name // renderActions 延迟到卡片自身渲染时才调用,保证 memo 生效 const actionContent = actions ?? renderActions?.(media) @@ -131,6 +133,11 @@ export const MediaCard = memo(function MediaCard({ }`}> {media.title}

+ {showLibrary && libraryName && ( +

+ {libraryName} +

+ )} {/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的 徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。 min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */} diff --git a/web/src/pages/MediaSearchPage.tsx b/web/src/pages/MediaSearchPage.tsx index c214cd8..fc36241 100644 --- a/web/src/pages/MediaSearchPage.tsx +++ b/web/src/pages/MediaSearchPage.tsx @@ -151,7 +151,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: { {!loading && items.length > 0 && (
{items.map((item) => ( - + ))}
)}