From e337d9932b40fe1b2a2d1eabd09cf088e47dd6fa Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:55:53 +0800 Subject: [PATCH] Fix Emby person image routing and search dropdown dismissal --- internal/handler/emby_images.go | 17 +++--- internal/handler/emby_person_image_test.go | 63 +++++++++++++++++++++ internal/service/emby_people_test.go | 4 +- internal/service/tmdb_people.go | 12 ++-- internal/service/tmdb_people_test.go | 2 +- web/src/components/LayoutHeaderSections.tsx | 26 +++++---- 6 files changed, 96 insertions(+), 28 deletions(-) create mode 100644 internal/handler/emby_person_image_test.go diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index bade726..3aacf45 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -29,7 +29,7 @@ var embyPlaceholderPNG = []byte{ // /api/img 会变成 401,所以这里复用 ImageProxy 但不再走 /api 路由。 func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag"), false) + embyServeImage(c, svc, c.Param("id"), c.Param("type"), c.Query("tag")) } } @@ -37,11 +37,11 @@ func embyItemImageHandler(svc *service.Container) gin.HandlerFunc { // Name 可能是伪装后的远程人物 ID,也可能是电影详情 People 中的显示名称。 func embyPersonImageHandler(svc *service.Container) gin.HandlerFunc { return func(c *gin.Context) { - embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag"), true) + embyServeImage(c, svc, c.Param("name"), c.Param("type"), c.Query("tag")) } } -func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string, person bool) { +func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag string) { clearEmbyImageNoStoreHeaders(c) ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second) defer cancel() @@ -50,13 +50,10 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s embyServePlaceholderImage(c) return } - var raw string - var err error - if person { - raw, err = svc.Emby.PersonImageURL(ctx, id, imageType, tag) - } else { - raw, err = svc.Emby.ImageURL(ctx, id, imageType) - } + // PersonImageURL handles TMDb/remote people first and falls back to regular + // media/library artwork, so the official /Items/{personId}/Images route + // works for synthetic person IDs as well as normal item IDs. + raw, err := svc.Emby.PersonImageURL(ctx, id, imageType, tag) if err != nil || raw == "" { embyServePlaceholderImage(c) return diff --git a/internal/handler/emby_person_image_test.go b/internal/handler/emby_person_image_test.go new file mode 100644 index 0000000..41ccc3a --- /dev/null +++ b/internal/handler/emby_person_image_test.go @@ -0,0 +1,63 @@ +package handler + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/service" +) + +func TestEmbyItemImageRouteResolvesTMDbPersonID(t *testing.T) { + imageData, err := base64.StdEncoding.DecodeString("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Zl9sAAAAASUVORK5CYII=") + if err != nil { + t.Fatal(err) + } + imageServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/t/p/w300/actor.jpg" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(imageData) + })) + defer imageServer.Close() + + cfg := &config.Config{ + Cache: config.CacheConfig{CacheDir: t.TempDir()}, + Secrets: config.SecretsConfig{TMDbImageProxy: imageServer.URL + "/t/p"}, + } + tmdb := service.NewTMDbProvider(cfg, zap.NewNop(), nil) + emby := service.NewEmbyService(cfg, zap.NewNop(), nil).SetTMDbProvider(tmdb) + proxy := service.NewImageProxy(cfg, zap.NewNop()) + imageURL, _ := url.Parse(imageServer.URL) + proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{imageURL.Host} }) + svc := &service.Container{Emby: emby, ImageProxy: proxy} + + raw, err := emby.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2") + if err != nil || raw != imageServer.URL+"/t/p/w300/actor.jpg" { + t.Fatalf("resolved raw=%q err=%v", raw, err) + } + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{ + {Key: "id", Value: "person~tmdb~101"}, + {Key: "type", Value: "Primary"}, + } + c.Request = httptest.NewRequest(http.MethodGet, "/emby/Items/person~tmdb~101/Images/Primary?tag=tmdb%3A%2Factor.jpg%3Fp2", nil) + embyItemImageHandler(svc)(c) + + if w.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", w.Code, w.Body.String()) + } + if got := len(w.Body.Bytes()); got != len(imageData) { + t.Fatalf("image bytes=%d want %d", got, len(imageData)) + } +} diff --git a/internal/service/emby_people_test.go b/internal/service/emby_people_test.go index 9698e01..d8e326a 100644 --- a/internal/service/emby_people_test.go +++ b/internal/service/emby_people_test.go @@ -96,10 +96,10 @@ func TestResolveMediaPeopleFetchesTMDbAndCaches(t *testing.T) { if hits.Load() != 1 { t.Fatalf("TMDb credits hits=%d want 1", hits.Load()) } - if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg" { + if people[0]["Id"] != "person~tmdb~101" || people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" { t.Fatalf("unexpected person: %#v", people[0]) } - raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg") + raw, err := svc.PersonImageURL(t.Context(), "person~tmdb~101", "Primary", "tmdb:/actor.jpg?p2") if err != nil { t.Fatal(err) } diff --git a/internal/service/tmdb_people.go b/internal/service/tmdb_people.go index d121f4b..ceecd0e 100644 --- a/internal/service/tmdb_people.go +++ b/internal/service/tmdb_people.go @@ -10,9 +10,10 @@ import ( ) const ( - tmdbPersonIDPrefix = "person~tmdb~" - tmdbProfileTagPrefix = "tmdb:" - tmdbPeopleMax = 80 + tmdbPersonIDPrefix = "person~tmdb~" + tmdbProfileTagPrefix = "tmdb:" + tmdbProfileTagVersion = "p2" + tmdbPeopleMax = 80 ) var tmdbProfilePathRE = regexp.MustCompile(`^/[A-Za-z0-9._-]+$`) @@ -220,7 +221,7 @@ func tmdbProfileTag(profilePath string) string { if !tmdbProfilePathRE.MatchString(profilePath) { return "" } - return tmdbProfileTagPrefix + profilePath + return tmdbProfileTagPrefix + profilePath + "?" + tmdbProfileTagVersion } func tmdbProfilePathFromTag(tag string) string { @@ -229,6 +230,9 @@ func tmdbProfilePathFromTag(tag string) string { return "" } profilePath := strings.TrimPrefix(tag, tmdbProfileTagPrefix) + if idx := strings.IndexByte(profilePath, '?'); idx >= 0 { + profilePath = profilePath[:idx] + } if !tmdbProfilePathRE.MatchString(profilePath) { return "" } diff --git a/internal/service/tmdb_people_test.go b/internal/service/tmdb_people_test.go index 6f3141e..6e79962 100644 --- a/internal/service/tmdb_people_test.go +++ b/internal/service/tmdb_people_test.go @@ -47,7 +47,7 @@ func TestTMDbGetPeopleAndProfileImage(t *testing.T) { if people[0]["Id"] != "person~tmdb~101" || people[0]["Role"] != "主角" { t.Fatalf("unexpected actor: %#v", people[0]) } - if people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg" { + if people[0]["PrimaryImageTag"] != "tmdb:/actor.jpg?p2" { t.Fatalf("unexpected actor image tag: %#v", people[0]) } if got := provider.ProfileImageURL("/actor.jpg"); got != "https://image.example/t/p/w300/actor.jpg" { diff --git a/web/src/components/LayoutHeaderSections.tsx b/web/src/components/LayoutHeaderSections.tsx index dc3af40..c9efec4 100644 --- a/web/src/components/LayoutHeaderSections.tsx +++ b/web/src/components/LayoutHeaderSections.tsx @@ -1,6 +1,6 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' -import { AnimatePresence, motion } from 'framer-motion' import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' import { imageURL } from '../api/client' @@ -320,13 +320,18 @@ function LayoutHeaderSearch() { {/* Search Dropdown Results */} - - {isOpen && query.trim() && ( - - )} - + + ) : null} ) }