From 91eb0dadf639a0a82c24c6b5562db2f93317a382 Mon Sep 17 00:00:00 2001
From: jason jia
Date: Fri, 16 Oct 2026 15:50:55 +0000
Subject: [PATCH] fix(search): keep same series separate across libraries
---
internal/handler/media_search_test.go | 111 ++++++++++++++++++++++++++
internal/service/media_search_test.go | 69 ++++++++++++++++
internal/service/media_series.go | 11 ++-
web/src/components/MediaCard.tsx | 9 ++-
web/src/pages/MediaSearchPage.tsx | 2 +-
5 files changed, 198 insertions(+), 4 deletions(-)
create mode 100644 internal/handler/media_search_test.go
create mode 100644 internal/service/media_search_test.go
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) => (
-
+
))}
)}