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
+1 -1
View File
@@ -77,7 +77,7 @@ func (c *CryptoService) Encrypt(plain string) string {
// Decrypt returns the plaintext for an encrypted value. Plaintext rows
// (no prefix) are returned unchanged.
func (c *CryptoService) Decrypt(value string) string {
if value == "" || c.aead == nil {
if c == nil || value == "" || c.aead == nil {
return value
}
if !strings.HasPrefix(value, encPrefix) {
+130 -3
View File
@@ -13,6 +13,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"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)
cards = append(cards, SeriesCard{Key: m.ID, Rep: m, LinkMedia: m, Count: 0})
}
if r.cache != nil {
r.cache.SetJSON(ctx, cacheKey, cards, r.remoteMediaCacheTTL())
if r.cache != nil {
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 流端点)。
+118 -2
View File
@@ -2,10 +2,17 @@ package service
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"go.uber.org/zap"
"github.com/truewhile/MeBox/internal/config"
"github.com/truewhile/MeBox/internal/model"
"github.com/truewhile/MeBox/internal/repository"
)
func TestMapRemoteItemToMediaSortingFields(t *testing.T) {
@@ -61,7 +68,116 @@ func TestMapRemoteItemToMediaCriticRatingFallback(t *testing.T) {
if media.Rating != 9.2 {
t.Fatalf("Rating = %f, want 9.2 from CriticRating", media.Rating)
}
if media.Year != 2022 {
t.Fatalf("Year = %d, want 2022 from PremiereDate", media.Year)
if media.Year != 2022 {
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))
}
}
+15
View File
@@ -44,6 +44,11 @@ func paginateMediaItems(items []MediaItem, page, pageSize int) []MediaItem {
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 {
if len(items) == 0 {
return nil
@@ -60,6 +65,11 @@ func firstMediaItems(items []MediaItem, limit int) []MediaItem {
return items[:limit]
}
// FirstMediaItems 导出截取前 N 项辅助函数。
func FirstMediaItems(items []MediaItem, limit int) []MediaItem {
return firstMediaItems(items, limit)
}
func groupMediaVersions(items []model.Media) []MediaItem {
if len(items) == 0 {
return nil
@@ -104,6 +114,11 @@ func groupMediaVersions(items []model.Media) []MediaItem {
return out
}
// GroupMediaVersions 导出多版本分组函数。
func GroupMediaVersions(items []model.Media) []MediaItem {
return groupMediaVersions(items)
}
func mediaVersionGroupKey(m model.Media) string {
if m.SeasonNum > 0 || m.EpisodeNum > 0 {
switch {