mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 21:06:38 +08:00
优化
This commit is contained in:
@@ -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 {
|
func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
|
visibility := mediaVisibilityForRequest(c, svc)
|
||||||
groupVersions := c.DefaultQuery("group_versions", "1") != "0"
|
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") != "" {
|
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||||
if !groupVersions {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
remoteItems := fetchRemote(size * 2)
|
||||||
|
all := append(localItems, remoteItems...)
|
||||||
|
paged := paginateSlice(all, page, size)
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": paged,
|
||||||
"total": total,
|
"total": len(all),
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": size,
|
"page_size": size,
|
||||||
})
|
})
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": paged,
|
||||||
"total": total,
|
"total": len(grouped),
|
||||||
"page": page,
|
"page": page,
|
||||||
"page_size": size,
|
"page_size": size,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
if !groupVersions {
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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})
|
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,8 +436,180 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
|||||||
if strings.Contains(body, `"items":null`) {
|
if strings.Contains(body, `"items":null`) {
|
||||||
t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body)
|
t.Fatalf("%s: empty library returned items:null (crashes frontend): %s", tc.name, body)
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, `"items":[]`) {
|
if !strings.Contains(body, `"items":[]`) {
|
||||||
t.Fatalf("%s: expected items:[] for empty library, got %s", tc.name, body)
|
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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -16,16 +17,26 @@ import (
|
|||||||
// searchUnifiedHandler is the basic /api/search endpoint.
|
// searchUnifiedHandler is the basic /api/search endpoint.
|
||||||
func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
|
func searchUnifiedHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 30
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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)})
|
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.
|
// them back. This keeps API parity without a giant query builder.
|
||||||
func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
|
func searchAdvancedHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
q := c.Query("q")
|
q := c.Query("q")
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "30"))
|
||||||
if limit <= 0 || limit > 200 {
|
if limit <= 0 || limit > 200 {
|
||||||
limit = 30
|
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 {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
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{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"items": items,
|
"items": items,
|
||||||
"filters": gin.H{
|
"filters": gin.H{
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (c *CryptoService) Encrypt(plain string) string {
|
|||||||
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
|
||||||
// (no prefix) are returned unchanged.
|
// (no prefix) are returned unchanged.
|
||||||
func (c *CryptoService) Decrypt(value string) string {
|
func (c *CryptoService) Decrypt(value string) string {
|
||||||
if value == "" || c.aead == nil {
|
if c == nil || value == "" || c.aead == nil {
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(value, encPrefix) {
|
if !strings.HasPrefix(value, encPrefix) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
@@ -502,10 +503,136 @@ func (r *EmbyRemoteService) RemoteLatestCards(ctx context.Context, mount *model.
|
|||||||
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
m := r.MapRemoteItemToMedia(ctx, mount, acct, cfg, it)
|
||||||
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
|
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
|
||||||
}
|
}
|
||||||
if r.cache != nil {
|
if r.cache != nil {
|
||||||
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
|
||||||
|
}
|
||||||
|
return cards, nil
|
||||||
}
|
}
|
||||||
return cards, nil
|
|
||||||
|
// RemoteSearchMedia 在全部启用的挂载库中并发搜索影视条目(Movie,Series),
|
||||||
|
// 并将远程结果映射为 model.Media。遵循当前用户的 MediaVisibility 权限规则。
|
||||||
|
func (r *EmbyRemoteService) RemoteSearchMedia(ctx context.Context, query string, limit int, visibility MediaVisibility) ([]model.Media, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
} else if limit > maxMediaSearchLimit {
|
||||||
|
limit = maxMediaSearchLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
mounts, err := r.ListMounts(ctx)
|
||||||
|
if err != nil || len(mounts) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type mountTarget struct {
|
||||||
|
mount model.EmbyMount
|
||||||
|
acct *model.StrmAccount
|
||||||
|
cfg *EmbyRemoteConfig
|
||||||
|
}
|
||||||
|
var targets []mountTarget
|
||||||
|
for _, m := range mounts {
|
||||||
|
if !m.Enabled {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
libID := EncodeEmbyRemoteID(m.ID, m.RemoteViewID)
|
||||||
|
hidden := false
|
||||||
|
for _, hid := range visibility.HiddenLibraryIDs {
|
||||||
|
if hid == libID {
|
||||||
|
hidden = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hidden {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(visibility.AllowedLibraryIDs) > 0 {
|
||||||
|
allowed := false
|
||||||
|
for _, aid := range visibility.AllowedLibraryIDs {
|
||||||
|
if aid == libID {
|
||||||
|
allowed = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
acct := r.AccountByID(ctx, m.AccountID)
|
||||||
|
if acct == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg, cfgErr := r.configOf(acct)
|
||||||
|
if cfgErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
targets = append(targets, mountTarget{mount: m, acct: acct, cfg: cfg})
|
||||||
|
}
|
||||||
|
if len(targets) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
searchCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
sem := make(chan struct{}, 6)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
type searchResult struct {
|
||||||
|
items []model.Media
|
||||||
|
}
|
||||||
|
results := make([]searchResult, len(targets))
|
||||||
|
for i, t := range targets {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, target mountTarget) {
|
||||||
|
defer wg.Done()
|
||||||
|
select {
|
||||||
|
case sem <- struct{}{}:
|
||||||
|
defer func() { <-sem }()
|
||||||
|
case <-searchCtx.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("ParentId", target.mount.RemoteViewID)
|
||||||
|
q.Set("Recursive", "true")
|
||||||
|
q.Set("SearchTerm", query)
|
||||||
|
q.Set("IncludeItemTypes", "Movie,Series")
|
||||||
|
q.Set("Fields", "Overview,Genres,ProviderIds,Path,SeriesPrimaryImage,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating")
|
||||||
|
q.Set("Limit", strconv.Itoa(limit))
|
||||||
|
q.Set("StartIndex", "0")
|
||||||
|
|
||||||
|
var body struct {
|
||||||
|
Items []map[string]any `json:"Items"`
|
||||||
|
}
|
||||||
|
if err := r.doGet(searchCtx, target.acct, target.cfg, "/Users/"+url.PathEscape(r.remoteUserID(target.cfg))+"/Items", q, &body); err != nil {
|
||||||
|
if r.log != nil {
|
||||||
|
r.log.Warn("remote search failed",
|
||||||
|
zap.String("mount", target.mount.RemoteViewName), zap.Error(err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
medias := make([]model.Media, 0, len(body.Items))
|
||||||
|
for _, it := range body.Items {
|
||||||
|
RewriteEmbyRemoteIDs(it, target.mount.ID)
|
||||||
|
m := r.MapRemoteItemToMedia(searchCtx, &target.mount, target.acct, target.cfg, it)
|
||||||
|
medias = append(medias, m)
|
||||||
|
}
|
||||||
|
results[idx] = searchResult{items: medias}
|
||||||
|
}(i, t)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
var out []model.Media
|
||||||
|
for _, res := range results {
|
||||||
|
out = append(out, res.items...)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebStreamURL 远程条目的网页播放地址(302 直连远程 Emby 流端点)。
|
// WebStreamURL 远程条目的网页播放地址(302 直连远程 Emby 流端点)。
|
||||||
|
|||||||
@@ -2,10 +2,17 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
"github.com/truewhile/MeBox/internal/model"
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMapRemoteItemToMediaSortingFields(t *testing.T) {
|
func TestMapRemoteItemToMediaSortingFields(t *testing.T) {
|
||||||
@@ -61,7 +68,116 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
|
|||||||
if media.Rating != 9.2 {
|
if media.Rating != 9.2 {
|
||||||
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
|
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
|
||||||
}
|
}
|
||||||
if media.Year != 2022 {
|
if media.Year != 2022 {
|
||||||
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
|
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoteSearchMedia(t *testing.T) {
|
||||||
|
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 := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{})
|
||||||
|
repos := repository.New(db)
|
||||||
|
svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop()))
|
||||||
|
|
||||||
|
rawConfig, _ := json.Marshal(map[string]string{
|
||||||
|
"url": server.URL,
|
||||||
|
"token": "fake-token",
|
||||||
|
})
|
||||||
|
acct := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-1"},
|
||||||
|
Name: "test-emby",
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||||
|
t.Fatalf("create account: %v", 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.Fatalf("create mount: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 正常搜索
|
||||||
|
items, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteSearchMedia failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Fatalf("expected 1 item, got %d", len(items))
|
||||||
|
}
|
||||||
|
if items[0].Title != "碧蓝之海" {
|
||||||
|
t.Fatalf("expected Title '碧蓝之海', got %q", items[0].Title)
|
||||||
|
}
|
||||||
|
expectedID := EncodeEmbyRemoteID("mount-1", "156030")
|
||||||
|
if items[0].ID != expectedID {
|
||||||
|
t.Fatalf("expected ID %q, got %q", expectedID, items[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 搜索不到的内容
|
||||||
|
notFound, err := svc.RemoteSearchMedia(t.Context(), "其它不存在的剧", 10, MediaVisibility{IncludeNSFW: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteSearchMedia failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(notFound) != 0 {
|
||||||
|
t.Fatalf("expected 0 items, got %d", len(notFound))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 白名单过滤:当白名单不包含该挂载虚拟库 ID 时应过滤掉
|
||||||
|
allowedLibID := "local-lib-1"
|
||||||
|
filtered, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{
|
||||||
|
IncludeNSFW: true,
|
||||||
|
AllowedLibraryIDs: []string{allowedLibID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteSearchMedia with allowed filter failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(filtered) != 0 {
|
||||||
|
t.Fatalf("expected 0 items due to AllowedLibraryIDs, got %d", len(filtered))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 黑名单过滤:当黑名单包含该挂载虚拟库 ID 时应过滤掉
|
||||||
|
mountLibID := EncodeEmbyRemoteID("mount-1", "view-1")
|
||||||
|
hiddenFiltered, err := svc.RemoteSearchMedia(t.Context(), "碧蓝之海", 10, MediaVisibility{
|
||||||
|
IncludeNSFW: true,
|
||||||
|
HiddenLibraryIDs: []string{mountLibID},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RemoteSearchMedia with hidden filter failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(hiddenFiltered) != 0 {
|
||||||
|
t.Fatalf("expected 0 items due to HiddenLibraryIDs, got %d", len(hiddenFiltered))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
|
|||||||
return items[start:end]
|
return items[start:end]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PaginateMediaItems 导出分页辅助函数。
|
||||||
|
func PaginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
|
||||||
|
return paginateMediaItems(items, page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
func firstMediaItems(items []MediaItem, limit int) []MediaItem {
|
func firstMediaItems(items []MediaItem, limit int) []MediaItem {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -60,6 +65,11 @@ func firstMediaItems(items []MediaItem, limit int) []MediaItem {
|
|||||||
return items[:limit]
|
return items[:limit]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FirstMediaItems 导出截取前 N 项辅助函数。
|
||||||
|
func FirstMediaItems(items []MediaItem, limit int) []MediaItem {
|
||||||
|
return firstMediaItems(items, limit)
|
||||||
|
}
|
||||||
|
|
||||||
func groupMediaVersions(items []model.Media) []MediaItem {
|
func groupMediaVersions(items []model.Media) []MediaItem {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -104,6 +114,11 @@ func groupMediaVersions(items []model.Media) []MediaItem {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GroupMediaVersions 导出多版本分组函数。
|
||||||
|
func GroupMediaVersions(items []model.Media) []MediaItem {
|
||||||
|
return groupMediaVersions(items)
|
||||||
|
}
|
||||||
|
|
||||||
func mediaVersionGroupKey(m model.Media) string {
|
func mediaVersionGroupKey(m model.Media) string {
|
||||||
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
|
||||||
switch {
|
switch {
|
||||||
|
|||||||
Reference in New Issue
Block a user