mirror of
https://github.com/truewhile/MeBox.git
synced 2026-10-05 04:56:37 +08:00
支持 Emby 演职人员头像代理并优化媒体系列搜索
This commit is contained in:
@@ -29,24 +29,44 @@ var embyPlaceholderPNG = []byte{
|
|||||||
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
// /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。
|
||||||
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
func embyItemImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
clearEmbyImageNoStoreHeaders(c)
|
embyServeImage(c, svc, c.Param("id"), c.Param("type"), false)
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
}
|
||||||
defer cancel()
|
}
|
||||||
req := c.Request.WithContext(ctx)
|
|
||||||
id := c.Param("id")
|
// embyPersonImageHandler 兼容 Emby 官方的 /Persons/{Name}/Images/{Type}。
|
||||||
imgType := strings.ToLower(c.Param("type"))
|
// Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。
|
||||||
raw, err := svc.Emby.ImageURL(ctx, id, imgType)
|
func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc {
|
||||||
if err != nil || raw == "" {
|
return func(c *gin.Context) {
|
||||||
embyServePlaceholderImage(c)
|
embyServeImage(c, svc, c.Param("name"), c.Param("type"), true)
|
||||||
return
|
}
|
||||||
}
|
}
|
||||||
if svc.ImageProxy == nil {
|
|
||||||
embyServePlaceholderImage(c)
|
func embyServeImage(c *gin.Context, svc *service.Container, id, imageType string, person bool) {
|
||||||
return
|
clearEmbyImageNoStoreHeaders(c)
|
||||||
}
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||||
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
defer cancel()
|
||||||
embyServePlaceholderImage(c)
|
req := c.Request.WithContext(ctx)
|
||||||
}
|
if svc == nil || svc.Emby == nil {
|
||||||
|
embyServePlaceholderImage(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var raw string
|
||||||
|
var err error
|
||||||
|
if person {
|
||||||
|
raw, err = svc.Emby.PersonImageURL(ctx, id, imageType)
|
||||||
|
} else {
|
||||||
|
raw, err = svc.Emby.ImageURL(ctx, id, imageType)
|
||||||
|
}
|
||||||
|
if err != nil || raw == "" {
|
||||||
|
embyServePlaceholderImage(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if svc.ImageProxy == nil {
|
||||||
|
embyServePlaceholderImage(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := svc.ImageProxy.Serve(ctx, c.Writer, req, raw); err != nil {
|
||||||
|
embyServePlaceholderImage(c)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,6 +128,13 @@ func registerEmbyPublicImageRoutes(grp *gin.RouterGroup, svc *service.Container)
|
|||||||
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
grp.GET("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||||
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
grp.GET("/items/:id/images/:type/:index", embyItemImageHandler(svc))
|
||||||
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
grp.HEAD("/items/:id/images/:type", embyItemImageHandler(svc))
|
||||||
|
// 官方 Emby 客户端也可能使用 /Persons/{Name}/Images/{Type} 获取演职人员头像。
|
||||||
|
grp.GET("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||||
|
grp.GET("/Persons/:name/Images/:type/:index", embyPersonImageHandler(svc))
|
||||||
|
grp.HEAD("/Persons/:name/Images/:type", embyPersonImageHandler(svc))
|
||||||
|
grp.GET("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||||
|
grp.GET("/persons/:name/images/:type/:index", embyPersonImageHandler(svc))
|
||||||
|
grp.HEAD("/persons/:name/images/:type", embyPersonImageHandler(svc))
|
||||||
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
// 用户头像。没有头像时返回带缓存头的 404,避免客户端反复重试。
|
||||||
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
grp.GET("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||||
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
grp.HEAD("/Users/:userId/Images/:type", embyUserImageHandler(svc))
|
||||||
|
|||||||
@@ -563,6 +563,39 @@ func searchMediaHandler(svc *service.Container) gin.HandlerFunc {
|
|||||||
return remoteItems
|
return remoteItems
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.DefaultQuery("group_series", "0") != "0" {
|
||||||
|
localItems, err := svc.Media.SearchMediaVisible(ctx, q, 50000, visibility)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remoteItems := fetchRemote(50000)
|
||||||
|
all := service.GroupMediaSeriesItems(append(localItems, remoteItems...))
|
||||||
|
|
||||||
|
if c.Query("page") != "" || c.Query("page_size") != "" {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
size, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||||||
|
paged := paginateSlice(all, page, size)
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"items": paged,
|
||||||
|
"total": len(all),
|
||||||
|
"page": page,
|
||||||
|
"page_size": size,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if len(all) > limit {
|
||||||
|
all = all[:limit]
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"items": all})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
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"))
|
||||||
|
|||||||
@@ -447,6 +447,89 @@ func TestEmptyLibraryListsReturnEmptyArraysNotNull(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSearchMediaGroupsSeriesBeforeLimit(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&model.User{}, &model.Library{}, &model.Media{}, &model.Setting{}, &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)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
rows := []model.Media{
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "dbkai-ep-1", CreatedAt: now.Add(-2 * time.Minute), UpdatedAt: now.Add(-2 * time.Minute)},
|
||||||
|
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E01.mkv",
|
||||||
|
SeasonNum: 1, EpisodeNum: 1, TMDbID: 61709,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "dbkai-ep-2", CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute)},
|
||||||
|
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E02.mkv",
|
||||||
|
SeasonNum: 1, EpisodeNum: 2, TMDbID: 61709,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "dbkai-ep-3", CreatedAt: now, UpdatedAt: now},
|
||||||
|
LibraryID: lib.ID, Title: "龙珠改", Path: "/media/anime/龙珠改 (2009)/Season 1/龙珠改.S01E03.mkv",
|
||||||
|
SeasonNum: 1, EpisodeNum: 3, TMDbID: 61709,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{ID: "db-movie", CreatedAt: now.Add(-3 * time.Minute), UpdatedAt: now.Add(-3 * time.Minute)},
|
||||||
|
LibraryID: lib.ID, Title: "龙珠超:布罗利", Path: "/media/anime/龙珠超:布罗利 (2018)/龙珠超:布罗利.mkv",
|
||||||
|
TMDbID: 503314,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if err := db.Create(&rows).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc := &service.Container{
|
||||||
|
Repo: repos,
|
||||||
|
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Set(middleware.CtxUserID, "user-1")
|
||||||
|
c.Set(middleware.CtxUserRole, "user")
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/media?q=龙珠&limit=2&group_series=1", 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 []model.Media `json:"items"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(res.Items) != 2 {
|
||||||
|
t.Fatalf("expected one representative per series after limit, got %d: %#v", len(res.Items), res.Items)
|
||||||
|
}
|
||||||
|
seenSeries := false
|
||||||
|
seenMovie := false
|
||||||
|
for _, item := range res.Items {
|
||||||
|
switch item.TMDbID {
|
||||||
|
case 61709:
|
||||||
|
seenSeries = true
|
||||||
|
if item.EpisodeNum != 1 {
|
||||||
|
t.Fatalf("series representative episode=%d, want first episode", item.EpisodeNum)
|
||||||
|
}
|
||||||
|
case 503314:
|
||||||
|
seenMovie = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seenSeries || !seenMovie {
|
||||||
|
t.Fatalf("expected one Dragon Ball series and one movie, got %#v", res.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
func TestSearchMediaHandlerIncludesEmbyRemote(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -83,6 +83,24 @@ func (e *EmbyService) resolveVirtualArtwork(ctx context.Context, id, imageType s
|
|||||||
return pick(series.PosterURL, series.BackdropURL), nil
|
return pick(series.PosterURL, series.BackdropURL), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PersonImageURL resolves a person avatar from either a disguised remote ID
|
||||||
|
// or a person display name captured from a remote item's People field.
|
||||||
|
func (e *EmbyService) PersonImageURL(ctx context.Context, idOrName, imageType string) (string, error) {
|
||||||
|
idOrName = strings.TrimSpace(idOrName)
|
||||||
|
if idOrName == "" || e == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if IsEmbyRemoteID(idOrName) {
|
||||||
|
return e.ImageURL(ctx, idOrName, imageType)
|
||||||
|
}
|
||||||
|
if e.remote != nil {
|
||||||
|
if raw, ok := e.remote.ResolveRemotePersonImageURL(ctx, idOrName, imageType); ok {
|
||||||
|
return raw, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e.ImageURL(ctx, idOrName, imageType)
|
||||||
|
}
|
||||||
|
|
||||||
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
// imageInfoTypes 是 GET /Items/{Id}/Images 会报告的图片类型。只列 MeBox
|
||||||
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
// 真正存储的两类:ImageURL 对 Thumb / Logo / Banner 等其余类型会回退到
|
||||||
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
// 主图,若一并列出会让客户端以为存在这些图并去请求,实际拿到的却是主图。
|
||||||
|
|||||||
@@ -76,6 +76,14 @@ type EmbyRemoteService struct {
|
|||||||
http *http.Client
|
http *http.Client
|
||||||
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
|
stream *http.Client // 流式代理专用(视频/字幕),无整体 Timeout
|
||||||
cache *RuntimeCacheService
|
cache *RuntimeCacheService
|
||||||
|
|
||||||
|
personMu sync.RWMutex
|
||||||
|
personImages map[string]embyRemotePersonImageRef
|
||||||
|
}
|
||||||
|
|
||||||
|
type embyRemotePersonImageRef struct {
|
||||||
|
accountID string
|
||||||
|
remoteID string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
// NewEmbyRemoteService 构造远程 Emby 聚合服务。
|
||||||
@@ -802,10 +810,12 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
path := "/Users/" + url.PathEscape(r.remoteUserID(cfg)) + "/Items/" + url.PathEscape(remoteID)
|
||||||
|
q := url.Values{"Fields": {"Overview,Genres,ProviderIds,People,Studios,Path,MediaStreams,MediaSources,DateCreated,PremiereDate,ProductionYear,CommunityRating,CriticRating"}}
|
||||||
var out map[string]any
|
var out map[string]any
|
||||||
if err := r.doGet(ctx, acct, cfg, path, nil, &out); err != nil {
|
if err := r.doGet(ctx, acct, cfg, path, q, &out); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
r.rememberRemotePeople(mount, out)
|
||||||
RewriteEmbyRemoteIDs(out, mount.ID)
|
RewriteEmbyRemoteIDs(out, mount.ID)
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
@@ -959,6 +969,78 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm
|
|||||||
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
"?api_key=" + url.QueryEscape(cfg.Token), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式
|
||||||
|
// /Persons/{Name}/Images/{Type} 图片请求回源。客户端详情页通常先取条目详情,
|
||||||
|
// 此时 People 中的名称和 ID 已同时拿到,因此无需额外搜索远程人物。
|
||||||
|
func (r *EmbyRemoteService) rememberRemotePeople(mount *model.EmbyMount, payload map[string]any) {
|
||||||
|
if r == nil || mount == nil || strings.TrimSpace(mount.AccountID) == "" || payload == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
people := remotePeopleMaps(payload["People"])
|
||||||
|
if len(people) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.personMu.Lock()
|
||||||
|
defer r.personMu.Unlock()
|
||||||
|
if r.personImages == nil || len(r.personImages) > 20000 {
|
||||||
|
r.personImages = make(map[string]embyRemotePersonImageRef, 256)
|
||||||
|
}
|
||||||
|
for _, person := range people {
|
||||||
|
name := strings.TrimSpace(remoteItemString(person, "Name"))
|
||||||
|
remoteID := strings.TrimSpace(remoteItemString(person, "Id"))
|
||||||
|
if name == "" || remoteID == "" || IsEmbyRemoteID(remoteID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.personImages[strings.ToLower(name)] = embyRemotePersonImageRef{
|
||||||
|
accountID: mount.AccountID,
|
||||||
|
remoteID: remoteID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveRemotePersonImageURL 按人物名称解析其远程头像地址。
|
||||||
|
func (r *EmbyRemoteService) ResolveRemotePersonImageURL(ctx context.Context, name, imageType string) (string, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
key := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
if key == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
r.personMu.RLock()
|
||||||
|
ref, ok := r.personImages[key]
|
||||||
|
r.personMu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
acct := r.AccountByID(ctx, ref.accountID)
|
||||||
|
if acct == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
raw, err := r.RemoteImageURL(ctx, acct, ref.remoteID, imageType)
|
||||||
|
if err != nil || strings.TrimSpace(raw) == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return raw, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func remotePeopleMaps(value any) []map[string]any {
|
||||||
|
switch typed := value.(type) {
|
||||||
|
case []map[string]any:
|
||||||
|
return typed
|
||||||
|
case []any:
|
||||||
|
out := make([]map[string]any, 0, len(typed))
|
||||||
|
for _, item := range typed {
|
||||||
|
if person, ok := item.(map[string]any); ok {
|
||||||
|
out = append(out, person)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
// ─── 播放代理 ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
// ProxyVideoStream 反向代理远程 Emby 视频流(保留 Range 以支持拖动)。
|
||||||
|
|||||||
@@ -106,4 +106,9 @@ func rewriteEmbyRemoteIDsMap(m map[string]any, accountID string) {
|
|||||||
if items, ok := m["Items"]; ok {
|
if items, ok := m["Items"]; ok {
|
||||||
RewriteEmbyRemoteIDs(items, accountID)
|
RewriteEmbyRemoteIDs(items, accountID)
|
||||||
}
|
}
|
||||||
|
// 人物条目同样以 Id 回指 /Items/{Id}/Images/...。若不递归重写,客户端会
|
||||||
|
// 把远程演员 ID 当成本地 ID,头像最终只能命中占位图。
|
||||||
|
if people, ok := m["People"]; ok {
|
||||||
|
RewriteEmbyRemoteIDs(people, accountID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,14 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
|||||||
"Items": []any{
|
"Items": []any{
|
||||||
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
map[string]any{"Id": "item-2", "ParentId": "folder-2"},
|
||||||
},
|
},
|
||||||
|
"People": []any{
|
||||||
|
map[string]any{
|
||||||
|
"Id": "person-1",
|
||||||
|
"Name": "演员甲",
|
||||||
|
"Type": "Actor",
|
||||||
|
"PrimaryImageTag": "person-tag-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
// MediaSource 的 Id 保持原样(客户端仅作为 MediaSourceId 查询参数)。
|
||||||
"MediaSources": []any{
|
"MediaSources": []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
@@ -90,6 +98,13 @@ func TestRewriteEmbyRemoteIDs(t *testing.T) {
|
|||||||
if nested["Id"] != "embyremote~acct-1~item-2" {
|
if nested["Id"] != "embyremote~acct-1~item-2" {
|
||||||
t.Fatalf("nested Id = %v", nested["Id"])
|
t.Fatalf("nested Id = %v", nested["Id"])
|
||||||
}
|
}
|
||||||
|
person := payload["People"].([]any)[0].(map[string]any)
|
||||||
|
if person["Id"] != "embyremote~acct-1~person-1" {
|
||||||
|
t.Fatalf("person Id = %v", person["Id"])
|
||||||
|
}
|
||||||
|
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||||
|
t.Fatalf("person display fields changed: %#v", person)
|
||||||
|
}
|
||||||
|
|
||||||
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
// MediaSource.Id 与 URL 不被 ID 重写器触碰(URL 由代理模式函数改写)。
|
||||||
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
ms := payload["MediaSources"].([]any)[0].(map[string]any)
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/truewhile/MeBox/internal/config"
|
||||||
|
"github.com/truewhile/MeBox/internal/model"
|
||||||
|
"github.com/truewhile/MeBox/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRemoteItemRequestsPeopleAndRewritesPersonIDs(t *testing.T) {
|
||||||
|
var requestedFields string
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasSuffix(r.URL.Path, "/Users/user-1/Items/item-1") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
requestedFields = r.URL.Query().Get("Fields")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"Id": "item-1",
|
||||||
|
"Name": "测试电影",
|
||||||
|
"People": []map[string]any{
|
||||||
|
{
|
||||||
|
"Id": "person-1",
|
||||||
|
"Name": "演员甲",
|
||||||
|
"Type": "Actor",
|
||||||
|
"PrimaryImageTag": "person-tag-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
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",
|
||||||
|
"remote_user_id": "user-1",
|
||||||
|
})
|
||||||
|
acct := &model.StrmAccount{
|
||||||
|
Base: model.Base{ID: "acct-people"},
|
||||||
|
Provider: model.StrmProviderEmbyRemote,
|
||||||
|
Config: string(rawConfig),
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := repos.StrmAccount.Create(t.Context(), acct); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mount := &model.EmbyMount{Base: model.Base{ID: "mount-people"}, AccountID: acct.ID}
|
||||||
|
|
||||||
|
out, err := svc.RemoteItem(t.Context(), mount, acct, "item-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(requestedFields, "People") {
|
||||||
|
t.Fatalf("RemoteItem Fields = %q, want People", requestedFields)
|
||||||
|
}
|
||||||
|
people, ok := out["People"].([]any)
|
||||||
|
if !ok || len(people) != 1 {
|
||||||
|
t.Fatalf("People = %#v, want one person", out["People"])
|
||||||
|
}
|
||||||
|
person := people[0].(map[string]any)
|
||||||
|
if got := person["Id"]; got != EncodeEmbyRemoteID("mount-people", "person-1") {
|
||||||
|
t.Fatalf("person Id = %v, want encoded remote id", got)
|
||||||
|
}
|
||||||
|
if person["Name"] != "演员甲" || person["PrimaryImageTag"] != "person-tag-1" {
|
||||||
|
t.Fatalf("person display fields changed: %#v", person)
|
||||||
|
}
|
||||||
|
raw, ok := svc.ResolveRemotePersonImageURL(t.Context(), "演员甲", "Primary")
|
||||||
|
if !ok || !strings.Contains(raw, "/Items/person-1/Images/primary") {
|
||||||
|
t.Fatalf("resolved person image URL = %q, ok=%v", raw, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -327,6 +327,22 @@ func groupMediaSeriesCards(items []model.Media) []SeriesCard {
|
|||||||
return cards
|
return cards
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func GroupMediaSeriesItems(items []model.Media) []model.Media {
|
||||||
|
cards := groupMediaSeriesCards(items)
|
||||||
|
if len(cards) == 0 {
|
||||||
|
return []model.Media{}
|
||||||
|
}
|
||||||
|
out := make([]model.Media, 0, len(cards))
|
||||||
|
for _, card := range cards {
|
||||||
|
out = append(out, card.Rep)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func betterSeriesRepresentative(candidate, current model.Media) bool {
|
func betterSeriesRepresentative(candidate, current model.Media) bool {
|
||||||
// A theatrical feature can have local poster.jpg/background.jpg files and
|
// A theatrical feature can have local poster.jpg/background.jpg files and
|
||||||
// therefore a higher artwork score than its TV episodes. Keep the TV row as
|
// therefore a higher artwork score than its TV episodes. Keep the TV row as
|
||||||
|
|||||||
+12
-3
@@ -193,10 +193,18 @@ export const mediaAPI = {
|
|||||||
recent: (limit = 24) =>
|
recent: (limit = 24) =>
|
||||||
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
api.get<SeriesCard[]>('/media/recent', { params: { limit } }).then((r) => r.data),
|
||||||
|
|
||||||
search: (q: string, limit = 50) =>
|
search: (q: string, limit = 50, options?: { groupSeries?: boolean }) =>
|
||||||
api.get<MediaSearchPage>('/media', { params: { q, limit } }).then((r) => r.data),
|
api
|
||||||
|
.get<MediaSearchPage>('/media', {
|
||||||
|
params: {
|
||||||
|
q,
|
||||||
|
limit,
|
||||||
|
...(options?.groupSeries ? { group_series: 1 } : {}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((r) => r.data),
|
||||||
|
|
||||||
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean }) =>
|
searchPage: (q: string, page = 1, pageSize = 50, options?: { groupVersions?: boolean; groupSeries?: boolean }) =>
|
||||||
api
|
api
|
||||||
.get<MediaSearchPage>('/media', {
|
.get<MediaSearchPage>('/media', {
|
||||||
params: {
|
params: {
|
||||||
@@ -204,6 +212,7 @@ export const mediaAPI = {
|
|||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
group_versions: options?.groupVersions === false ? 0 : undefined,
|
group_versions: options?.groupVersions === false ? 0 : undefined,
|
||||||
|
...(options?.groupSeries ? { group_series: 1 } : {}),
|
||||||
},
|
},
|
||||||
timeout: LONG_REQUEST_TIMEOUT,
|
timeout: LONG_REQUEST_TIMEOUT,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -108,15 +108,23 @@ export function LayoutHeader({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SEARCH_PAGE_SIZE = 8
|
||||||
|
|
||||||
function LayoutHeaderSearch() {
|
function LayoutHeaderSearch() {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false)
|
||||||
const [results, setResults] = useState<Media[]>([])
|
const [results, setResults] = useState<Media[]>([])
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
const containerRef = useRef<HTMLDivElement>(null)
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
const isOpenRef = useRef(false)
|
const isOpenRef = useRef(false)
|
||||||
// 递增序号守卫:快速连续输入时丢弃过期响应
|
// 递增序号守卫:快速连续输入时丢弃过期响应
|
||||||
const searchSeqRef = useRef(0)
|
const searchSeqRef = useRef(0)
|
||||||
|
const pageRef = useRef(0)
|
||||||
|
const loadingMoreRef = useRef(false)
|
||||||
|
const resultsRef = useRef<Media[]>([])
|
||||||
|
const hasMoreRef = useRef(false)
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const setSearchOpen = (open: boolean) => {
|
const setSearchOpen = (open: boolean) => {
|
||||||
@@ -128,23 +136,43 @@ function LayoutHeaderSearch() {
|
|||||||
const trimmed = query.trim()
|
const trimmed = query.trim()
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
searchSeqRef.current += 1
|
searchSeqRef.current += 1
|
||||||
|
pageRef.current = 0
|
||||||
|
loadingMoreRef.current = false
|
||||||
|
resultsRef.current = []
|
||||||
|
hasMoreRef.current = false
|
||||||
setResults([])
|
setResults([])
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setLoadingMore(false)
|
||||||
|
setHasMore(false)
|
||||||
isOpenRef.current = false
|
isOpenRef.current = false
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const seq = ++searchSeqRef.current
|
const seq = ++searchSeqRef.current
|
||||||
|
pageRef.current = 0
|
||||||
|
loadingMoreRef.current = false
|
||||||
|
resultsRef.current = []
|
||||||
|
hasMoreRef.current = false
|
||||||
|
setResults([])
|
||||||
|
setHasMore(false)
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setLoadingMore(false)
|
||||||
const timer = setTimeout(async () => {
|
const timer = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await mediaAPI.search(trimmed, 8)
|
const res = await mediaAPI.searchPage(trimmed, 1, SEARCH_PAGE_SIZE, { groupSeries: true })
|
||||||
if (seq !== searchSeqRef.current) return
|
if (seq !== searchSeqRef.current) return
|
||||||
setResults(res.items || [])
|
const items = res.items || []
|
||||||
|
const total = res.total ?? items.length
|
||||||
|
const more = items.length < total
|
||||||
|
pageRef.current = 1
|
||||||
|
resultsRef.current = items
|
||||||
|
hasMoreRef.current = more
|
||||||
|
setResults(items)
|
||||||
|
setHasMore(more)
|
||||||
if (isOpenRef.current) setIsOpen(true)
|
if (isOpenRef.current) setIsOpen(true)
|
||||||
} catch {
|
} catch {
|
||||||
// 请求失败时保留旧结果,避免网络抖动清空下拉
|
// 请求失败时保持空结果,用户继续输入或滚动时会重新请求
|
||||||
} finally {
|
} finally {
|
||||||
if (seq === searchSeqRef.current) setLoading(false)
|
if (seq === searchSeqRef.current) setLoading(false)
|
||||||
}
|
}
|
||||||
@@ -164,6 +192,40 @@ function LayoutHeaderSearch() {
|
|||||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const loadMore = async () => {
|
||||||
|
const trimmed = query.trim()
|
||||||
|
if (!trimmed || loading || loadingMoreRef.current || !hasMoreRef.current) return
|
||||||
|
|
||||||
|
const seq = searchSeqRef.current
|
||||||
|
const nextPage = pageRef.current + 1
|
||||||
|
loadingMoreRef.current = true
|
||||||
|
setLoadingMore(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await mediaAPI.searchPage(trimmed, nextPage, SEARCH_PAGE_SIZE, { groupSeries: true })
|
||||||
|
if (seq !== searchSeqRef.current) return
|
||||||
|
|
||||||
|
const incoming = res.items || []
|
||||||
|
const currentResults = resultsRef.current
|
||||||
|
const known = new Set(currentResults.map((item) => item.id))
|
||||||
|
const nextResults = [...currentResults, ...incoming.filter((item) => !known.has(item.id))]
|
||||||
|
const total = res.total ?? nextResults.length
|
||||||
|
const more = nextPage * SEARCH_PAGE_SIZE < total
|
||||||
|
pageRef.current = nextPage
|
||||||
|
resultsRef.current = nextResults
|
||||||
|
hasMoreRef.current = more
|
||||||
|
setResults(nextResults)
|
||||||
|
setHasMore(more)
|
||||||
|
} catch {
|
||||||
|
// 请求失败时保留当前结果,继续滚动可重试
|
||||||
|
} finally {
|
||||||
|
if (seq === searchSeqRef.current) {
|
||||||
|
loadingMoreRef.current = false
|
||||||
|
setLoadingMore(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleSelect = (item: Media) => {
|
const handleSelect = (item: Media) => {
|
||||||
setSearchOpen(false)
|
setSearchOpen(false)
|
||||||
setQuery('')
|
setQuery('')
|
||||||
@@ -225,9 +287,20 @@ function LayoutHeaderSearch() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, y: 6 }}
|
exit={{ opacity: 0, y: 6 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
onScroll={(e) => {
|
||||||
|
const target = e.currentTarget
|
||||||
|
if (target.scrollHeight - target.scrollTop - target.clientHeight < 80) {
|
||||||
|
void loadMore()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="absolute top-full left-0 right-0 mt-2 max-h-96 overflow-y-auto overscroll-contain rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] p-2 shadow-2xl z-50 backdrop-blur-xl"
|
||||||
>
|
>
|
||||||
{results.length === 0 && !loading ? (
|
{results.length === 0 && loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-8 text-xs text-[var(--app-muted)]">
|
||||||
|
<LoaderCircle size={14} className="text-brand-500 animate-spin" />
|
||||||
|
正在搜索…
|
||||||
|
</div>
|
||||||
|
) : results.length === 0 ? (
|
||||||
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
<div className="py-8 text-center text-xs text-[var(--app-muted)]">
|
||||||
未搜索到与 “{query}” 相关的媒体内容
|
未搜索到与 “{query}” 相关的媒体内容
|
||||||
</div>
|
</div>
|
||||||
@@ -282,6 +355,17 @@ function LayoutHeaderSearch() {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{loadingMore && (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-3 text-[10px] text-[var(--app-muted)]">
|
||||||
|
<LoaderCircle size={12} className="text-brand-500 animate-spin" />
|
||||||
|
正在加载更多…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingMore && !hasMore && results.length > 0 && (
|
||||||
|
<div className="py-2 text-center text-[10px] text-[var(--app-muted)]">
|
||||||
|
已显示全部 {results.length} 条结果
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user