Fix Emby person image routing and search dropdown dismissal

This commit is contained in:
truewhile
2026-09-12 16:55:53 +08:00
parent df654ea47b
commit e337d9932b
6 changed files with 96 additions and 28 deletions
+7 -10
View File
@@ -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
@@ -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))
}
}
+2 -2
View File
@@ -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)
}
+8 -4
View File
@@ -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 ""
}
+1 -1
View File
@@ -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" {
+15 -11
View File
@@ -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() {
</div>
{/* Search Dropdown Results */}
<AnimatePresence>
{isOpen && query.trim() && (
<motion.div
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ duration: 0.15 }}
{isOpen && query.trim() && typeof document !== 'undefined'
? createPortal(
<div
aria-hidden="true"
className="fixed inset-0 z-[25]"
onPointerDown={() => dismissSearch(false)}
/>,
document.body,
)
: null}
{isOpen && query.trim() ? (
<div
onScroll={(e) => {
const target = e.currentTarget
if (target.scrollHeight - target.scrollTop - target.clientHeight < 80) {
@@ -414,9 +419,8 @@ function LayoutHeaderSearch() {
)}
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
) : null}
</div>
)
}