diff --git a/internal/handler/media.go b/internal/handler/media.go index 8fd813f..ff4d3f5 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -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) } }) diff --git a/internal/handler/media_libraries_preview_test.go b/internal/handler/media_libraries_preview_test.go new file mode 100644 index 0000000..7537b35 --- /dev/null +++ b/internal/handler/media_libraries_preview_test.go @@ -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)) + } +} diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 598042f..805398e 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -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. diff --git a/internal/service/image_resize.go b/internal/service/image_resize.go index 63b7a8d..6c2286e 100644 --- a/internal/service/image_resize.go +++ b/internal/service/image_resize.go @@ -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 原子, // 持锁只会把缩略图的写盘和原图的写盘串成一条队。 diff --git a/internal/service/image_resize_test.go b/internal/service/image_resize_test.go index c495dd0..6997fcb 100644 --- a/internal/service/image_resize_test.go +++ b/internal/service/image_resize_test.go @@ -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") } } diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx index ef943ac..2e2b652 100644 --- a/web/src/pages/SettingsPage.tsx +++ b/web/src/pages/SettingsPage.tsx @@ -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 = {} 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) => {