mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
fix(search): keep same series separate across libraries
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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}
|
||||
</p>
|
||||
{showLibrary && libraryName && (
|
||||
<p title={libraryName} className="truncate text-[10px] text-[var(--app-muted)] sm:text-xs">
|
||||
{libraryName}
|
||||
</p>
|
||||
)}
|
||||
{/* 单行、不折行:年份可截断,徽标永不压缩。折行会让同一行里各卡片的
|
||||
徽标落位高低不一,看起来就是「错位」,所以宁可极窄屏上截断年份。
|
||||
min-h 统一行高,避免有/无徽标的卡片年份基线差几像素。 */}
|
||||
|
||||
@@ -151,7 +151,7 @@ function MediaSearchResults({ cacheKey, query, page, onPageChange }: {
|
||||
{!loading && items.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6">
|
||||
{items.map((item) => (
|
||||
<MediaCard key={item.id} media={item} linkTo={favouriteMediaLink(item)} linkState={{ from }} />
|
||||
<MediaCard key={item.id} media={item} showLibrary linkTo={favouriteMediaLink(item)} linkState={{ from }} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user