This commit is contained in:
truewhile
2026-09-02 18:26:31 +08:00
parent e923c9d7a1
commit 5cd1aa3e91
7 changed files with 529 additions and 19 deletions
+68 -9
View File
@@ -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})
}
}
+174 -2
View File
@@ -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())
}
}
}
+23 -2
View File
@@ -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{