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{}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user