mirror of
https://github.com/truewhile/MeBox.git
synced 2026-11-12 19:34:24 +08:00
优化
This commit is contained in:
@@ -42,6 +42,11 @@ type webLibraryPayload struct {
|
||||
Cards []service.SeriesCardView `json:"cards,omitempty"`
|
||||
}
|
||||
|
||||
// remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby
|
||||
// 库预览(总条数 + 最新卡片)的总耗时。这些预览只用于首页卡片,值得为它
|
||||
// 排队等待的量很小;此前它跟随请求上下文,上游超时会让整页等 15 秒以上。
|
||||
var remoteLibraryPreviewBudget = 3 * time.Second
|
||||
|
||||
// remoteLibraryItemTypes 远程库内容拉取时按 CollectionType 过滤直属条目,
|
||||
// 避免电影库里的合集文件夹(Folder) 漏出为电影卡片。
|
||||
func remoteLibraryItemTypes(collectionType string) string {
|
||||
@@ -178,6 +183,12 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
remotePayloads[i] = webLibraryPayload{Library: v.Library, IsRemoteEmby: true, RemoteSource: v.AccountName}
|
||||
}
|
||||
if withPreview && len(allowedViews) > 0 {
|
||||
// 远程库预览只是首页卡片上的「最新几条」,绝不能拖住整个
|
||||
// 库列表:上游 Emby 一旦超时,每个远程库都要等满
|
||||
// embyRemoteHTTPTimeout(15s),串起来就是 17–30 秒。
|
||||
// 给整段预览一个总预算,超时就只返回库名,卡片留给下一次刷新。
|
||||
previewCtx, cancelPreview := context.WithTimeout(ctx, remoteLibraryPreviewBudget)
|
||||
defer cancelPreview()
|
||||
const maxRemotePreviewWorkers = 6
|
||||
sem := make(chan struct{}, maxRemotePreviewWorkers)
|
||||
var wg sync.WaitGroup
|
||||
@@ -189,11 +200,11 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
defer func() { <-sem }()
|
||||
case <-ctx.Done():
|
||||
case <-previewCtx.Done():
|
||||
return
|
||||
}
|
||||
helper.Run(svc.Log, "media.remotePreview", func() {
|
||||
acct := svc.EmbyRemote.AccountByID(ctx, v.AccountID)
|
||||
acct := svc.EmbyRemote.AccountByID(previewCtx, v.AccountID)
|
||||
if acct == nil {
|
||||
return
|
||||
}
|
||||
@@ -205,10 +216,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc {
|
||||
Name: v.Library.Name,
|
||||
}
|
||||
itemTypes := remoteLibraryItemTypes(v.CollectionType)
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(ctx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
if _, total, err := svc.EmbyRemote.RemoteLibraryMedia(previewCtx, tmpMount, acct, v.RemoteID, itemTypes, 0, 1); err == nil {
|
||||
remotePayloads[i].Total = total
|
||||
}
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(ctx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
if cards, err := svc.EmbyRemote.RemoteLatestCards(previewCtx, tmpMount, acct, v.RemoteID, limit); err == nil {
|
||||
remotePayloads[i].Cards = service.NewSeriesCardViews(cards)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/glebarez/sqlite"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/truewhile/MeBox/internal/config"
|
||||
"github.com/truewhile/MeBox/internal/middleware"
|
||||
"github.com/truewhile/MeBox/internal/model"
|
||||
"github.com/truewhile/MeBox/internal/repository"
|
||||
"github.com/truewhile/MeBox/internal/service"
|
||||
)
|
||||
|
||||
// TestListLibrariesRemotePreviewIsBounded 锁定远程库预览的总预算:
|
||||
// 上游 Emby 卡住时,/api/libraries?with_preview=1 必须按时返回远程库条目
|
||||
// (只是没有卡片),而不是跟着上游一起等十几秒。线上实测这个接口因此出现过
|
||||
// 17.4 秒、30 秒(前端放弃)的响应,设置页也跟着整页空转。
|
||||
func TestListLibrariesRemotePreviewIsBounded(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
oldBudget := remoteLibraryPreviewBudget
|
||||
remoteLibraryPreviewBudget = 200 * time.Millisecond
|
||||
t.Cleanup(func() { remoteLibraryPreviewBudget = oldBudget })
|
||||
|
||||
// Views / 用户列表正常返回,Preview(Items 查询)永远不返回:模拟上游半挂。
|
||||
release := make(chan struct{})
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
path := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(path, "/Views"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"Items": []map[string]any{{
|
||||
"Id": "view-1", "Name": "远程动画", "CollectionType": "tvshows",
|
||||
}},
|
||||
})
|
||||
return
|
||||
case strings.HasSuffix(path, "/Users"):
|
||||
// api_key 账号要先解析出远程用户 Id,否则请求路径会落到兜底 "0"。
|
||||
_ = json.NewEncoder(w).Encode([]map[string]any{{"Id": "uid-1", "Name": "admin"}})
|
||||
return
|
||||
case r.Method == http.MethodPost:
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"AccessToken": "fake-token",
|
||||
"User": map[string]any{"Id": "uid-1"},
|
||||
})
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-release:
|
||||
case <-r.Context().Done():
|
||||
case <-time.After(30 * time.Second):
|
||||
}
|
||||
w.WriteHeader(http.StatusGatewayTimeout)
|
||||
}))
|
||||
defer upstream.Close()
|
||||
defer close(release)
|
||||
|
||||
// 唯一库名 + cache=shared:预览协程会并发读库(账号解析)。
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", strings.ReplaceAll(t.Name(), "/", "_"))
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.StrmAccount{}, &model.EmbyMount{}, &model.Setting{}, &model.User{}, &model.PlayProfile{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
repos := repository.New(db)
|
||||
if err := db.Create(&model.User{Base: model.Base{ID: "admin-1"}, Username: "admin", Role: "admin"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawConfig, _ := json.Marshal(map[string]string{"url": upstream.URL, "token": "fake-token"})
|
||||
acct := model.StrmAccount{
|
||||
Base: model.Base{ID: "acct-1"}, Name: "远程Emby", 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-1"}, AccountID: acct.ID, RemoteViewID: "view-1",
|
||||
RemoteViewName: "远程动画", CollectionType: "tvshows", Enabled: true,
|
||||
}
|
||||
if err := repos.EmbyMount.Create(t.Context(), &mount); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
svc := &service.Container{
|
||||
Repo: repos,
|
||||
Media: service.NewMediaService(&config.Config{}, zap.NewNop(), repos),
|
||||
EmbyRemote: service.NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, service.NewCryptoService("", zap.NewNop())),
|
||||
Cache: service.NewRuntimeCacheService(&config.Config{}, zap.NewNop()),
|
||||
}
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Set(middleware.CtxUserID, "admin-1")
|
||||
c.Set(middleware.CtxUserRole, "admin")
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/api/libraries?with_preview=1&include_hidden=1", nil)
|
||||
|
||||
start := time.Now()
|
||||
listLibrariesHandler(svc)(c)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
if elapsed > 2*time.Second {
|
||||
t.Fatalf("request took %v with a hung upstream; the preview budget must bound it", elapsed)
|
||||
}
|
||||
var payload []webLibraryPayload
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode response: %v (%s)", err, w.Body.String())
|
||||
}
|
||||
var remote *webLibraryPayload
|
||||
for i := range payload {
|
||||
if payload[i].IsRemoteEmby {
|
||||
remote = &payload[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if remote == nil {
|
||||
t.Fatalf("remote library missing from the bounded response: %#v", payload)
|
||||
}
|
||||
if len(remote.Cards) != 0 || remote.Total != 0 {
|
||||
t.Fatalf("expected the preview to be skipped on timeout, got total=%d cards=%d", remote.Total, len(remote.Cards))
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,10 @@ type ImageProxy struct {
|
||||
resizeSemMu sync.Mutex
|
||||
resizeSem chan struct{}
|
||||
|
||||
// resizeQueueWait 是一次请求最多为一格解码槽排队的时长。为 0 时用
|
||||
// defaultResizeQueueWait;测试会把它调小以免真的等待两秒。
|
||||
resizeQueueWait time.Duration
|
||||
|
||||
// libraryRootsFn returns the configured media library roots so that
|
||||
// sidecar poster/artwork files stored alongside media (under arbitrary
|
||||
// per-library paths) are allowed by isAllowedLocalPath. It is provided
|
||||
@@ -86,6 +90,12 @@ const (
|
||||
// imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们
|
||||
// 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。
|
||||
imageRenditionCacheSubdir = "renditions"
|
||||
|
||||
// defaultResizeQueueWait 是请求为一格解码槽排队的最长时间。一张 3MP
|
||||
// 海报的解码+缩放约 0.15s,2 核机器两格槽位足以在几秒内消化一整屏
|
||||
// 海报;等待远好于「槽位忙就回退多兆字节原图」(那样每张图在外部网络
|
||||
// 下要十几秒)。超时后仍回退原图,保证请求不会被无限期挂住。
|
||||
defaultResizeQueueWait = 3 * time.Second
|
||||
)
|
||||
|
||||
// NewImageProxy is the constructor.
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
@@ -138,8 +139,8 @@ func fitSize(srcW, srcH, maxW, maxH int) (int, int) {
|
||||
|
||||
var errImageResizeTooLarge = errors.New("image source exceeds resize pixel budget")
|
||||
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示原图
|
||||
// 本身已满足目标尺寸,调用方应直接输出原始字节(不重复编码、不损失质量)。
|
||||
// resizeImageData 按选项缩放图片并重新编码。返回 unchanged=true 表示源图
|
||||
// 已经是可直接下发的紧凑字节,调用方应直接输出原始字节(不重复编码)。
|
||||
func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype string, unchanged bool, err error) {
|
||||
if len(data) == 0 || !o.active() {
|
||||
return data, detectContentType(data), true, nil
|
||||
@@ -151,12 +152,21 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 {
|
||||
return nil, "", false, errors.New("invalid image dimensions")
|
||||
}
|
||||
// 先用 DecodeConfig 判断是否需要解码整图:图已够小就零成本返回原字节。
|
||||
// 先用 DecodeConfig 判断是否需要缩放。尺寸已在目标框内时仍要重编码:
|
||||
// 刮削来源的海报常是 1000×1500、1MB 以上的 PNG,直接原样下发会让一处
|
||||
// 海报墙就从「几十 KB 缩略图」变成「几十 MB 原图」——外部网络下每张
|
||||
// 都要十几秒。只有本来就是紧凑 Web 格式(JPEG/WebP)且体积很小的源图
|
||||
// 才值得零成本直出。
|
||||
dstW, dstH := fitSize(cfg.Width, cfg.Height, o.MaxWidth, o.MaxHeight)
|
||||
if dstW == cfg.Width && dstH == cfg.Height {
|
||||
unchangedSize := dstW == cfg.Width && dstH == cfg.Height
|
||||
if unchangedSize && isCompactWebFormat(format) && len(data) <= compactImageSkipBytes {
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > imageResizeMaxSourcePixels {
|
||||
if unchangedSize {
|
||||
// 尺寸已满足但像素量超出解码预算:宁可直出原图,也不冒 OOM 风险。
|
||||
return data, detectContentType(data), true, nil
|
||||
}
|
||||
return nil, "", false, errImageResizeTooLarge
|
||||
}
|
||||
|
||||
@@ -165,7 +175,12 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin
|
||||
return nil, "", false, err
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH))
|
||||
resizeScaled(dst, src, cfg.Width, cfg.Height)
|
||||
if unchangedSize {
|
||||
// 不缩放时省掉一次全图重采样,只做格式转换。
|
||||
draw.Draw(dst, dst.Bounds(), src, src.Bounds().Min, draw.Src)
|
||||
} else {
|
||||
resizeScaled(dst, src, cfg.Width, cfg.Height)
|
||||
}
|
||||
|
||||
// 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。
|
||||
// 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明
|
||||
@@ -231,9 +246,12 @@ func isOpaqueImage(img *image.RGBA) bool {
|
||||
// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含
|
||||
// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接
|
||||
// 从已缓存的大图缩小,而不是重新解码原图)。
|
||||
//
|
||||
// 前缀里的 v3 是缓存格式版本:v2 时期「尺寸已在目标框内」的 PNG 会原样直出、
|
||||
// 不写缓存,而现在同样条件会重编码成 JPEG,旧缓存与新结果语义不同,必须换键。
|
||||
func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string {
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "v2|%s", sourceID)
|
||||
_, _ = fmt.Fprintf(h, "v3|%s", sourceID)
|
||||
if stat != nil {
|
||||
_, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano())
|
||||
}
|
||||
@@ -356,13 +374,53 @@ func (p *ImageProxy) tryAcquireResizeSlot() (func(), bool) {
|
||||
}
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对中等体积的图只尝试一次槽位。忙则放弃缩放,
|
||||
// 避免背景图墙在 2 核机器上排成数秒。超大原图仍等待,以免直出多兆字节。
|
||||
func (p *ImageProxy) resizeQueueTimeout() time.Duration {
|
||||
if p != nil && p.resizeQueueWait > 0 {
|
||||
return p.resizeQueueWait
|
||||
}
|
||||
return defaultResizeQueueWait
|
||||
}
|
||||
|
||||
// acquireResizeSlotFor 对紧凑小图只尝试一次槽位,其余情况最多排队
|
||||
// resizeQueueTimeout。
|
||||
//
|
||||
// 从前这里对 1.5MB 以下的图一律「槽位忙就跳过缩放」,结果是并发加载海报墙
|
||||
// 时几乎每张图都回退成 1MB 上下的 PNG 原图:一页要传几十 MB,外部网络下单张
|
||||
// 图就要二十多秒。缩略图本身只有几十 KB,排队几百毫秒明显更划算;只有真正
|
||||
// 大到会拖慢页面的原图才值得在超时后直出。
|
||||
func (p *ImageProxy) acquireResizeSlotFor(ctx context.Context, size int64) (func(), bool) {
|
||||
if size > 0 && size <= resizeQueueBypassBytes {
|
||||
if size > 0 && size <= compactImageSkipBytes {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
return p.acquireResizeSlot(ctx)
|
||||
return p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout())
|
||||
}
|
||||
|
||||
// acquireResizeSlotWithin 在 timeout 内等待一格解码槽;超时或请求取消返回
|
||||
// false,调用方回退原图。
|
||||
func (p *ImageProxy) acquireResizeSlotWithin(ctx context.Context, timeout time.Duration) (func(), bool) {
|
||||
if p == nil {
|
||||
return func() {}, true
|
||||
}
|
||||
if timeout <= 0 {
|
||||
return p.tryAcquireResizeSlot()
|
||||
}
|
||||
p.resizeSemMu.Lock()
|
||||
if p.resizeSem == nil {
|
||||
p.resizeSem = make(chan struct{}, imageResizeConcurrency())
|
||||
}
|
||||
sem := p.resizeSem
|
||||
p.resizeSemMu.Unlock()
|
||||
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case sem <- struct{}{}:
|
||||
return func() { <-sem }, true
|
||||
case <-timer.C:
|
||||
return nil, false
|
||||
case <-ctx.Done():
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// serveResizedFromFile 从 srcPath 读取图片,按选项缩放后写出,并把结果缓存
|
||||
@@ -472,10 +530,6 @@ func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o im
|
||||
// ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。
|
||||
const compactImageSkipBytes = 200 * 1024
|
||||
|
||||
// resizeQueueBypassBytes 是“槽位忙就放弃缩放”的上限。超过它的原图继续排队,
|
||||
// 以免把未压缩的剧照直接发给客户端。
|
||||
const resizeQueueBypassBytes = 1536 * 1024
|
||||
|
||||
// serveCompactOriginal 在源文件已经很小且是浏览器可直接显示的 JPEG/WebP 时
|
||||
// 跳过解码。返回 false 表示仍应走缩放路径。
|
||||
func serveCompactOriginal(w http.ResponseWriter, r *http.Request, srcPath string, stat os.FileInfo) bool {
|
||||
@@ -504,6 +558,17 @@ func isCompactWebImage(header []byte) bool {
|
||||
string(header[8:12]) == "WEBP"
|
||||
}
|
||||
|
||||
// isCompactWebFormat 判断 image.DecodeConfig 报告的格式是否已经适合直接下发
|
||||
// 给浏览器。PNG/BMP/GIF 即使尺寸很小也可能有几百 KB,需要重编码成 JPEG。
|
||||
func isCompactWebFormat(format string) bool {
|
||||
switch strings.ToLower(format) {
|
||||
case "jpeg", "jpg", "webp":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// writeResizeCache 原子写入缩放结果;失败只记日志,不影响本次响应。
|
||||
// 与 writeImageCache 一样不再持有全局锁:临时文件名唯一、rename 原子,
|
||||
// 持锁只会把缩略图的写盘和原图的写盘串成一条队。
|
||||
|
||||
@@ -123,17 +123,58 @@ func TestResizeImageDataScalesDownAndReencodesAsJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestResizeImageDataLeavesImagesWithinBoundsUntouched(t *testing.T) {
|
||||
// 尺寸已经在目标框内的 PNG 也必须转成 JPEG。刮削来的海报常是 1000×1500、
|
||||
// 1MB 级的 PNG;此前这种情况原样直出,一处海报墙就要下载几十 MB,外部网络下
|
||||
// 每张图要十几秒(线上实测单张 1.3MB / 20 多秒)。
|
||||
func TestResizeImageDataReencodesPNGWithinBounds(t *testing.T) {
|
||||
data := encodeTestPNG(t, 200, 300, 255)
|
||||
out, _, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600, Quality: 80})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if unchanged {
|
||||
t.Fatal("PNG within the target box must still be re-encoded as a compact web format")
|
||||
}
|
||||
if ctype != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", ctype)
|
||||
}
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode re-encoded image: %v", err)
|
||||
}
|
||||
if format != "jpeg" {
|
||||
t.Fatalf("format = %q, want jpeg", format)
|
||||
}
|
||||
if cfg.Width != 200 || cfg.Height != 300 {
|
||||
t.Fatalf("re-encoded to %dx%d, want the original 200x300 (no upscaling)", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// 已经是紧凑 Web 格式且体积很小的源图仍然零成本直出。
|
||||
func TestResizeImageDataLeavesSmallCompactSourceUntouched(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
img := image.NewRGBA(image.Rect(0, 0, 200, 300))
|
||||
for y := 0; y < 300; y++ {
|
||||
for x := 0; x < 200; x++ {
|
||||
img.Set(x, y, color.RGBA{R: uint8(x % 16 * 16), G: uint8(y % 16 * 16), B: 200, A: 255})
|
||||
}
|
||||
}
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 80}); err != nil {
|
||||
t.Fatalf("encode test jpeg: %v", err)
|
||||
}
|
||||
data := buf.Bytes()
|
||||
if len(data) > compactImageSkipBytes {
|
||||
t.Fatalf("test jpeg is %d bytes, expected a compact source", len(data))
|
||||
}
|
||||
out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400, MaxHeight: 600})
|
||||
if err != nil {
|
||||
t.Fatalf("resize: %v", err)
|
||||
}
|
||||
if !unchanged {
|
||||
t.Fatal("expected an image already within bounds to be returned unchanged")
|
||||
t.Fatal("a compact JPEG already within bounds should be served unchanged")
|
||||
}
|
||||
if !bytes.Equal(out, data) {
|
||||
t.Fatal("expected the original bytes to be returned verbatim")
|
||||
if !bytes.Equal(out, data) || ctype != "image/jpeg" {
|
||||
t.Fatalf("expected the original jpeg bytes back, got %d bytes of %q", len(out), ctype)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +343,9 @@ func TestServeResizedFromFileSkipsDecodeForCompactJPEG(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
// 排队上限:槽位一直不空时,请求最多等 resizeQueueWait,然后回退原图,
|
||||
// 不会被无限期挂住。这条约束保护的是请求本身,而不是「不等就直出原图」。
|
||||
func TestServeResizedFromFileFallsBackWhenResizeQueueStaysBusy(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "backdrop.jpg"
|
||||
data := make([]byte, compactImageSkipBytes+1024)
|
||||
@@ -310,7 +353,7 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
if err := os.WriteFile(src, data, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache", resizeQueueWait: 50 * time.Millisecond}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
@@ -327,10 +370,77 @@ func TestServeResizedFromFileDoesNotQueueWhenResizeBusy(t *testing.T) {
|
||||
|
||||
start := time.Now()
|
||||
handled := proxy.serveResizedFromFile(httptest.NewRecorder(), httptest.NewRequest("GET", "/x?maxWidth=480", nil), src, imageResizeOptions{MaxWidth: 480})
|
||||
if time.Since(start) > 200*time.Millisecond {
|
||||
t.Fatal("resize slot was busy but the request still waited in the queue")
|
||||
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
|
||||
t.Fatalf("request waited %v with every slot held; the queue wait must be bounded", elapsed)
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("expected a busy resize slot to fall back so the caller can serve the original")
|
||||
t.Fatal("expected a permanently busy queue to fall back so the caller can serve the original")
|
||||
}
|
||||
}
|
||||
|
||||
// 槽位只是短暂忙碌时必须排队等待并产出缩略图,而不是直出多兆字节原图。
|
||||
// 这是线上「海报墙每张图 20 多秒」的根因:并发加载时槽位全忙,旧逻辑把
|
||||
// 1MB 级 PNG 原样下发。
|
||||
// encodeNoisyPNG 生成难以压缩的 PNG:规则色块会被压到 1KB 级别,测不出
|
||||
// 「大体积 PNG 被原样下发」这条回归。
|
||||
func encodeNoisyPNG(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
seed := uint32(0x9e3779b9)
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
seed = seed*1664525 + 1013904223
|
||||
img.Set(x, y, color.RGBA{R: uint8(seed >> 24), G: uint8(seed >> 16), B: uint8(seed >> 8), A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode noisy png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func TestServeResizedFromFileWaitsForSlotInsteadOfServingOriginal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
src := dir + string(os.PathSeparator) + "poster.png"
|
||||
original := encodeNoisyPNG(t, 378, 540)
|
||||
if len(original) <= compactImageSkipBytes {
|
||||
t.Fatalf("test poster is %d bytes, want a source above the compact threshold", len(original))
|
||||
}
|
||||
if err := os.WriteFile(src, original, 0o644); err != nil {
|
||||
t.Fatalf("write source: %v", err)
|
||||
}
|
||||
proxy := &ImageProxy{cacheDir: dir + string(os.PathSeparator) + "cache"}
|
||||
var releases []func()
|
||||
for i := 0; i < imageResizeConcurrency(); i++ {
|
||||
release, ok := proxy.acquireResizeSlot(context.Background())
|
||||
if !ok {
|
||||
t.Fatal("expected a resize slot")
|
||||
}
|
||||
releases = append(releases, release)
|
||||
}
|
||||
go func() {
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
releases[0]()
|
||||
}()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
|
||||
t.Fatal("expected the request to wait for the slot and serve a thumbnail")
|
||||
}
|
||||
body := rec.Body.Bytes()
|
||||
if bytes.Equal(body, original) {
|
||||
t.Fatal("served the multi-hundred-KB PNG original instead of a re-encoded thumbnail")
|
||||
}
|
||||
if rec.Header().Get("Content-Type") != "image/jpeg" {
|
||||
t.Fatalf("content type = %q, want image/jpeg", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
// 同一请求第二次必须直接命中缩放缓存。
|
||||
rec2 := httptest.NewRecorder()
|
||||
if !proxy.serveResizedFromFile(rec2, httptest.NewRequest("GET", "/x?maxWidth=480&maxHeight=600", nil), src, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) {
|
||||
t.Fatal("expected the cached thumbnail to be reused")
|
||||
}
|
||||
if !bytes.Equal(rec2.Body.Bytes(), body) {
|
||||
t.Fatal("expected the cached thumbnail bytes to be identical")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,27 +36,34 @@ export function SettingsPage() {
|
||||
}
|
||||
}, [searchParams])
|
||||
|
||||
// refresh 只等真正需要的设置项返回。媒体库列表仅供「成人库」等下拉选项
|
||||
// 使用,晚到不影响设置项渲染;此前用 Promise.all 一并等待,远程 Emby
|
||||
// 挂载枚举偶尔要 10–17 秒,整页就一直空转。
|
||||
const refresh = async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const [all, libs] = await Promise.all([
|
||||
adminAPI.listSettings(),
|
||||
libraryAPI.list({ includeHidden: true }).catch(() => [] as Library[]),
|
||||
])
|
||||
const all = await adminAPI.listSettings()
|
||||
const idx: Record<string, string> = {}
|
||||
for (const s of all as Setting[]) {
|
||||
if (ALL_KEYS.has(s.key)) idx[s.key] = s.value
|
||||
}
|
||||
setValues(idx)
|
||||
setLibraries(libs as Library[])
|
||||
setDirty(new Set())
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLibraries = () => {
|
||||
libraryAPI
|
||||
.list({ includeHidden: true })
|
||||
.then((libs) => setLibraries(libs as Library[]))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh().catch(() => undefined)
|
||||
refreshLibraries()
|
||||
}, [])
|
||||
|
||||
const onChange = (key: string, value: string) => {
|
||||
|
||||
Reference in New Issue
Block a user