mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Fix Emby person image routing and search dropdown dismissal
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user