From 54b0e339687e098fcfc65b739f0e2ff51142099f Mon Sep 17 00:00:00 2001 From: truewhile <62226914+truewhile@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:44:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/defaults.go | 3 +- internal/config/normalize.go | 3 - internal/config/types.go | 6 +- internal/database/schema_migration.go | 5 + internal/handler/admin_settings.go | 2 +- internal/handler/emby_images.go | 19 + internal/handler/library_cover.go | 100 ++++ internal/handler/library_cover_route_test.go | 118 ++++ internal/handler/library_cover_test.go | 228 +++++++ internal/handler/media.go | 33 +- internal/handler/routes_authenticated_core.go | 3 + internal/service/cache_cleanup.go | 56 +- internal/service/cache_cleanup_pools_test.go | 88 +-- .../service/emby_cache_invalidate_test.go | 254 ++++++++ internal/service/emby_compat.go | 20 + internal/service/emby_items_cache.go | 11 +- internal/service/emby_items_detail.go | 2 +- internal/service/emby_series.go | 170 ++++-- internal/service/emby_series_cache.go | 32 +- internal/service/emby_system.go | 29 +- internal/service/emby_user_data.go | 16 +- internal/service/image_proxy.go | 8 +- internal/service/library_cover.go | 564 ++++++++++++++++++ internal/service/library_cover_test.go | 335 +++++++++++ internal/service/runtime_settings.go | 7 - internal/service/scheduler.go | 2 +- internal/service/scheduler_local_jobs.go | 8 +- internal/service/service_builder.go | 4 - web/src/pages/HomePageSections.tsx | 4 +- web/src/pages/librariesPageModel.ts | 7 + web/src/pages/settingsGroupGeneral.ts | 8 - web/src/types/library.ts | 6 + 32 files changed, 1974 insertions(+), 177 deletions(-) create mode 100644 internal/handler/library_cover.go create mode 100644 internal/handler/library_cover_route_test.go create mode 100644 internal/handler/library_cover_test.go create mode 100644 internal/service/emby_cache_invalidate_test.go create mode 100644 internal/service/library_cover.go create mode 100644 internal/service/library_cover_test.go diff --git a/internal/config/defaults.go b/internal/config/defaults.go index add9619..20b4fa2 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -47,12 +47,11 @@ func setDefaults(v *viper.Viper) { v.SetDefault("cache.cache_dir", "./cache") v.SetDefault("cache.images_max_size_mb", 2000) v.SetDefault("cache.images_originals_max_size_mb", 256) - v.SetDefault("cache.images_originals_ttl_hours", 168) v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB) v.SetDefault("cache.redis_url", "") v.SetDefault("cache.redis_prefix", "mebox") v.SetDefault("cache.media_ttl_seconds", 90) - v.SetDefault("cache.emby_latest_ttl_seconds", 300) + v.SetDefault("cache.emby_latest_ttl_seconds", 900) v.SetDefault("cache.reader_content_max_size_mb", 1024) v.SetDefault("cache.reader_content_ttl_hours", 168) v.SetDefault("cache.reader_files_ttl_hours", 720) diff --git a/internal/config/normalize.go b/internal/config/normalize.go index 9973071..5eed465 100644 --- a/internal/config/normalize.go +++ b/internal/config/normalize.go @@ -50,9 +50,6 @@ func (c *Config) normalize() error { if c.Cache.ImagesOriginalsMaxSizeMB < 0 { c.Cache.ImagesOriginalsMaxSizeMB = 0 } - if c.Cache.ImagesOriginalsTTLHours < 0 { - c.Cache.ImagesOriginalsTTLHours = 0 - } if c.Cache.MemoryMaxSizeMB <= 0 { c.Cache.MemoryMaxSizeMB = DefaultCacheMemoryMaxSizeMB } diff --git a/internal/config/types.go b/internal/config/types.go index 6106dd2..fd0881d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -125,9 +125,6 @@ type CacheConfig struct { // ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是 // 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。 ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"` - // ImagesOriginalsTTLHours 是原图的保留时长(小时),超过即删除;0 表示 - // 不按时间淘汰。缩短它能让原图缓存保持在一个滚动窗口内。 - ImagesOriginalsTTLHours int `mapstructure:"images_originals_ttl_hours"` // MemoryMaxSizeMB 限制进程内 L1 缓存总字节数,JSON/对象缓存共用该预算。 MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"` RedisURL string `mapstructure:"redis_url"` @@ -136,7 +133,8 @@ type CacheConfig struct { // EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。 // 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个 // 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机 - // 上造成秒级延迟。默认 300 秒,新入库内容最迟 5 分钟后出现在最新列表。 + // 上造成秒级延迟。默认 900 秒;过期后仍会先返回陈旧副本并在后台重建, + // 新入库内容最迟 15 分钟后出现在最新列表(扫描完成会主动失效缓存)。 EmbyLatestTTLSeconds int `mapstructure:"emby_latest_ttl_seconds"` // ReaderContentMaxSizeMB 阅读正文持久缓存的总容量(MB),0 表示不限制。 ReaderContentMaxSizeMB int `mapstructure:"reader_content_max_size_mb"` diff --git a/internal/database/schema_migration.go b/internal/database/schema_migration.go index 1313702..f912f63 100644 --- a/internal/database/schema_migration.go +++ b/internal/database/schema_migration.go @@ -376,6 +376,11 @@ func ensurePerformanceIndexes(db *gorm.DB) error { `DROP INDEX IF EXISTS idx_media_library_release_active`, `CREATE INDEX IF NOT EXISTS idx_media_library_created_active ON media(library_id, created_at DESC) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_episode_active ON media(library_id, season_num, episode_num, created_at DESC) WHERE deleted_at IS NULL`, + // 虚拟剧集分组索引:series_id 为空的剧集行要按 + // (season_num, episode_num, episode_fraction, created_at) 排序后做内存 + // 分组。没有它 SQLite 只能全表扫描再建临时 B-tree,而 Emby 的 + // 卡片/海报查询每次只关心其中一部剧(生产环境实测单次 1.3s)。 + `CREATE INDEX IF NOT EXISTS idx_media_virtual_series_group ON media(season_num, episode_num, episode_fraction, created_at) WHERE deleted_at IS NULL AND (series_id IS NULL OR series_id = '')`, `CREATE INDEX IF NOT EXISTS idx_media_library_root_active ON media(library_id, library_root_id) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_series_active ON media(series_id, season_num, episode_num) WHERE deleted_at IS NULL`, `CREATE INDEX IF NOT EXISTS idx_media_library_series_active ON media(library_id, series_id, season_num, episode_num) WHERE deleted_at IS NULL`, diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index b303398..6665665 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -87,7 +87,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { if req.Key == "transcode.hw_enabled" || req.Key == "transcode.hw_accel" || req.Key == "transcoder.hardware_accel" || req.Key == "transcoder.encoder" { svc.Transcoder.StopAll() } - if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" { + if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" { if svc.Scheduler != nil { _ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup") } diff --git a/internal/handler/emby_images.go b/internal/handler/emby_images.go index 3aacf45..0182d98 100644 --- a/internal/handler/emby_images.go +++ b/internal/handler/emby_images.go @@ -50,6 +50,25 @@ func embyServeImage(c *gin.Context, svc *service.Container, id, imageType, tag s embyServePlaceholderImage(c) return } + // 媒体库主图:按 Emby 的做法,用库内几张成员海报拼一张封面(folder + // image)。没有自定义封面时才生成;自定义封面继续走下面的代理路径。 + if svc.ImageProxy != nil && strings.EqualFold(strings.TrimSpace(imageType), "Primary") { + if sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id); lib != nil && + strings.TrimSpace(lib.CoverURL) == "" && len(sources) > 0 { + width, height := libraryCoverRequestSize(c) + if data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height); err == nil && len(data) > 0 { + c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") + c.Header("Content-Type", "image/jpeg") + c.Header("Content-Length", strconv.Itoa(len(data))) + if c.Request.Method == http.MethodHead { + c.Status(http.StatusOK) + return + } + c.Data(http.StatusOK, "image/jpeg", data) + return + } + } + } // 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. diff --git a/internal/handler/library_cover.go b/internal/handler/library_cover.go new file mode 100644 index 0000000..3929509 --- /dev/null +++ b/internal/handler/library_cover.go @@ -0,0 +1,100 @@ +package handler + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "github.com/truewhile/MeBox/internal/service" +) + +// libraryCoverHandler 输出媒体库封面图(Emby 风格的成员海报拼图)。 +// +// 之前 Web 端是拿两张成员海报在前端用 CSS 拼格子,Emby 客户端则直接拿一张 +// 评分最高的海报,结果同一个库在不同客户端长得完全不一样。现在服务端生成 +// 一张 16:9 的拼图(Emby 的 folder image 做法),Emby 客户端、Web 和 App +// 都指向这一份,外观统一。 +func libraryCoverHandler(svc *service.Container) gin.HandlerFunc { + return func(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" || svc == nil || svc.ImageProxy == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "cover unavailable"}) + return + } + ctx, cancel := context.WithTimeout(c.Request.Context(), 25*time.Second) + defer cancel() + + sources, lib := service.LibraryCoverSourcesForLibrary(ctx, svc.Repo, id) + if lib == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "library not found"}) + return + } + if strings.TrimSpace(lib.CoverURL) != "" { + // 管理员自定义封面优先,保持既有行为(代理该地址)。 + if err := svc.ImageProxy.Serve(ctx, c.Writer, c.Request, lib.CoverURL); err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "custom cover unavailable"}) + } + return + } + if len(sources) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "no library items with artwork"}) + return + } + width, height := libraryCoverRequestSize(c) + data, err := svc.ImageProxy.LibraryCoverImage(ctx, id, sources, width, height) + if err != nil || len(data) == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "cover generation failed"}) + return + } + writeLibraryCoverImage(c, data, http.StatusOK) + } +} + +// libraryCoverRequestSize 解析请求的封面尺寸:Web 用 w/h,Emby 用 +// maxWidth/maxHeight,两种都兼容。服务端统一输出 16:9 画布(与该库在 Emby +// 里上报的 PrimaryImageAspectRatio 一致),客户端传的尺寸只当作「不超过」的 +// 边界使用。 +func libraryCoverRequestSize(c *gin.Context) (int, int) { + return firstPositiveQueryIntParam(c, "maxWidth", "maxwidth", "width", "w"), + firstPositiveQueryIntParam(c, "maxHeight", "maxheight", "height", "h") +} + +func firstPositiveQueryIntParam(c *gin.Context, names ...string) int { + for _, name := range names { + raw := strings.TrimSpace(c.Query(name)) + if raw == "" { + continue + } + if n, err := strconv.Atoi(raw); err == nil && n > 0 { + return n + } + if f, err := strconv.ParseFloat(raw, 64); err == nil && f > 0 { + return int(f) + } + } + return 0 +} + +func writeLibraryCoverImage(c *gin.Context, data []byte, status int) { + sum := sha256.Sum256(data) + etag := `"` + hex.EncodeToString(sum[:8]) + `"` + c.Header("ETag", etag) + c.Header("Cache-Control", "public, max-age=300, stale-while-revalidate=86400") + if match := strings.TrimSpace(c.GetHeader("If-None-Match")); match != "" && match == etag { + c.Status(http.StatusNotModified) + return + } + c.Header("Content-Type", "image/jpeg") + c.Header("Content-Length", strconv.Itoa(len(data))) + if c.Request.Method == http.MethodHead { + c.Status(status) + return + } + c.Data(status, "image/jpeg", data) +} diff --git a/internal/handler/library_cover_route_test.go b/internal/handler/library_cover_route_test.go new file mode 100644 index 0000000..0e37140 --- /dev/null +++ b/internal/handler/library_cover_route_test.go @@ -0,0 +1,118 @@ +package handler + +import ( + "bytes" + "image/color" + "image/jpeg" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "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" +) + +func coverTestToken(t *testing.T, secret, userID string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, middleware.Claims{ + UserID: userID, + Role: "admin", + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + IssuedAt: jwt.NewNumericDate(time.Now()), + Subject: userID, + }, + }) + signed, err := token.SignedString([]byte(secret)) + if err != nil { + t.Fatalf("sign token: %v", err) + } + return signed +} + +// TestLibraryCoverRouteServesImageThroughFullRouter 走完整的 handler.Register +// 路由表请求封面:既验证路由挂载正确,也验证鉴权链(AuthRequired + +// activeUserRequired)不会把它挡成 404。 +func TestLibraryCoverRouteServesImageThroughFullRouter(t *testing.T) { + gin.SetMode(gin.TestMode) + const secret = "test-secret" + + poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(poster) + })) + defer upstream.Close() + parsed, _ := url.Parse(upstream.URL) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}, &model.User{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + user := model.User{Base: model.Base{ID: "user-1"}, Username: "viewer", IsActive: true} + if err := db.Create(&user).Error; err != nil { + t.Fatalf("create user: %v", err) + } + lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for i := 0; i < 4; i++ { + row := model.Media{ + Base: model.Base{ID: "m" + string(rune('a'+i))}, + LibraryID: lib.ID, + Title: "Movie " + string(rune('A'+i)), + Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv", + PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg", + } + if err := db.Create(&row).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + cfg := &config.Config{ + Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}, + Secrets: config.SecretsConfig{JWTSecret: secret}, + } + proxy := service.NewImageProxy(cfg, zap.NewNop()) + proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} }) + svc := &service.Container{Repo: repos, ImageProxy: proxy} + + engine := gin.New() + Register(engine, cfg, zap.NewNop(), svc) + + token := coverTestToken(t, secret, user.ID) + req := httptest.NewRequest(http.MethodGet, "/api/libraries/"+lib.ID+"/cover?w=480&h=270&api_key="+token, nil) + w := httptest.NewRecorder() + engine.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { + t.Fatalf("content type = %q, want image/jpeg", ct) + } + img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatalf("decode cover: %v", err) + } + if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 { + t.Fatalf("cover size = %v, want 480x270", img.Bounds()) + } +} diff --git a/internal/handler/library_cover_test.go b/internal/handler/library_cover_test.go new file mode 100644 index 0000000..b41bc43 --- /dev/null +++ b/internal/handler/library_cover_test.go @@ -0,0 +1,228 @@ +package handler + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "testing" + + "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/model" + "github.com/truewhile/MeBox/internal/repository" + "github.com/truewhile/MeBox/internal/service" +) + +func coverTestJPEG(t *testing.T, c color.RGBA) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, 160, 240)) + for y := 0; y < 240; y++ { + for x := 0; x < 160; x++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 90}); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} + +// newLibraryCoverTestRouter 构造一个带真实图片代理的路由:成员海报来自 +// 本地 httptest 服务,走的是与生产一致的 ImageProxy 抓取路径。 +func newLibraryCoverTestRouter(t *testing.T) (*gin.Engine, *repository.Container, *httptest.Server) { + t.Helper() + gin.SetMode(gin.TestMode) + + poster := coverTestJPEG(t, color.RGBA{R: 200, G: 120, B: 60, A: 255}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(poster) + })) + t.Cleanup(upstream.Close) + + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatalf("migrate: %v", err) + } + repos := repository.New(db) + + parsed, err := url.Parse(upstream.URL) + if err != nil { + t.Fatalf("parse upstream: %v", err) + } + cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}} + proxy := service.NewImageProxy(cfg, zap.NewNop()) + proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} }) + + svc := &service.Container{Repo: repos, ImageProxy: proxy} + router := gin.New() + router.GET("/libraries/:id/cover", libraryCoverHandler(svc)) + router.HEAD("/libraries/:id/cover", libraryCoverHandler(svc)) + return router, repos, upstream +} + +func seedCoverLibrary(t *testing.T, repos *repository.Container, posterBase string, coverURL string) model.Library { + t.Helper() + lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", Enabled: true, CoverURL: coverURL} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for i := 0; i < 4; i++ { + row := model.Media{ + Base: model.Base{ID: "m" + string(rune('a'+i))}, + LibraryID: lib.ID, + Title: "Movie " + string(rune('A'+i)), + Path: "/media/movies/m" + string(rune('a'+i)) + ".mkv", + PosterURL: posterBase + "/p" + string(rune('1'+i)) + ".jpg", + } + if err := repos.DB.Create(&row).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + return lib +} + +// TestLibraryCoverHandlerServesGeneratedCollage 覆盖 Web/App 的封面接口: +// 返回的是服务端拼好的 16:9 图片,而不是单张成员海报。 +func TestLibraryCoverHandlerServesGeneratedCollage(t *testing.T) { + router, repos, upstream := newLibraryCoverTestRouter(t) + lib := seedCoverLibrary(t, repos, upstream.URL, "") + + req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + if ct := w.Header().Get("Content-Type"); ct != "image/jpeg" { + t.Fatalf("content type = %q, want image/jpeg", ct) + } + img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatalf("decode cover: %v", err) + } + if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 { + t.Fatalf("cover size = %v, want 480x270", img.Bounds()) + } + if etag := w.Header().Get("ETag"); etag == "" { + t.Fatal("expected an ETag so clients can revalidate") + } + + // 条件请求应拿到 304。 + req = httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover?w=480&h=270", nil) + req.Header.Set("If-None-Match", w.Header().Get("ETag")) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + if rec.Code != http.StatusNotModified { + t.Fatalf("conditional request status = %d, want 304", rec.Code) + } +} + +func TestLibraryCoverHandlerUnknownLibraryReturns404(t *testing.T) { + router, _, _ := newLibraryCoverTestRouter(t) + req := httptest.NewRequest(http.MethodGet, "/libraries/missing/cover", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", w.Code) + } +} + +// TestLibraryCoverHandlerServesCustomCover 管理员填了 cover_url 时必须继续 +// 用自定义封面,不能在不知不觉中换成拼图。 +func TestLibraryCoverHandlerServesCustomCover(t *testing.T) { + router, repos, upstream := newLibraryCoverTestRouter(t) + lib := seedCoverLibrary(t, repos, upstream.URL, upstream.URL+"/custom.jpg") + + req := httptest.NewRequest(http.MethodGet, "/libraries/"+lib.ID+"/cover", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + if _, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())); err != nil { + t.Fatalf("custom cover should be an image: %v", err) + } +} + +// TestEmbyLibraryPrimaryUsesGeneratedCollage 保证 Emby 客户端请求库主图时 +// 拿到的也是拼图(与 Web/App 同一份生成逻辑)。 +func TestEmbyLibraryPrimaryUsesGeneratedCollage(t *testing.T) { + gin.SetMode(gin.TestMode) + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if err := db.AutoMigrate(&model.Library{}, &model.Media{}); err != nil { + t.Fatalf("migrate: %v", err) + } + + poster := coverTestJPEG(t, color.RGBA{G: 180, R: 90, B: 60, A: 255}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(poster) + })) + defer upstream.Close() + parsed, _ := url.Parse(upstream.URL) + + repos := repository.New(db) + lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true} + if err := repos.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + for i := 0; i < 4; i++ { + row := model.Media{ + Base: model.Base{ID: "e" + string(rune('a'+i))}, + LibraryID: lib.ID, + Title: "Show " + string(rune('A'+i)), + Path: "/media/tv/show" + string(rune('a'+i)) + "/S01E01.mkv", + SeasonNum: 1, EpisodeNum: 1, + PosterURL: upstream.URL + "/p" + string(rune('1'+i)) + ".jpg", + } + if err := repos.DB.Create(&row).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + cfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}} + proxy := service.NewImageProxy(cfg, zap.NewNop()) + proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} }) + svc := &service.Container{ + Repo: repos, + Emby: service.NewEmbyService(cfg, zap.NewNop(), repos), + ImageProxy: proxy, + } + router := gin.New() + registerEmbyRoutes(router, "test-secret", svc) + + req := httptest.NewRequest(http.MethodGet, "/Items/"+lib.ID+"/Images/Primary?maxWidth=400&maxHeight=300", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + img, err := jpeg.Decode(bytes.NewReader(w.Body.Bytes())) + if err != nil { + t.Fatalf("decode emby library cover: %v", err) + } + // 请求的是 400×300 的盒子,拼图按 16:9 装进去 → 400×225。 + if img.Bounds().Dx() != 400 || img.Bounds().Dy() != 225 { + t.Fatalf("cover size = %v, want 400x225 (16:9 inside the requested box)", img.Bounds()) + } +} diff --git a/internal/handler/media.go b/internal/handler/media.go index ff4d3f5..75dde6d 100644 --- a/internal/handler/media.go +++ b/internal/handler/media.go @@ -7,6 +7,7 @@ import ( "fmt" "math" "net/http" + "net/url" "sort" "strconv" "strings" @@ -40,6 +41,25 @@ type webLibraryPayload struct { RemoteSource string `json:"remote_source,omitempty"` Total int64 `json:"total,omitempty"` Cards []service.SeriesCardView `json:"cards,omitempty"` + // CoverImageURL 是客户端可直接使用的「有效封面」:管理员自定义的 + // cover_url 优先,否则指向服务端生成的成员海报拼图接口。Web / App 都 + // 读这一个字段,避免各自用卡片在前端拼格子、长得还不一样。 + CoverImageURL string `json:"cover_image_url,omitempty"` +} + +// effectiveLibraryCoverURL 计算库封面的有效地址。远程挂载库的封面由上游 +// Emby 决定,这里返回空串,调用方继续用卡片拼图兜底。 +func effectiveLibraryCoverURL(lib model.Library, isRemote bool) string { + if isRemote { + return "" + } + if raw := strings.TrimSpace(lib.CoverURL); raw != "" { + return raw + } + if strings.TrimSpace(lib.ID) == "" { + return "" + } + return "/api/libraries/" + url.PathEscape(lib.ID) + "/cover" } // remoteLibraryPreviewBudget 限制 /api/libraries?with_preview=1 里远程 Emby @@ -124,9 +144,10 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { } for _, p := range previews { out = append(out, webLibraryPayload{ - Library: p.Library, - Total: p.Total, - Cards: service.NewSeriesCardViews(p.Cards), + Library: p.Library, + Total: p.Total, + Cards: service.NewSeriesCardViews(p.Cards), + CoverImageURL: effectiveLibraryCoverURL(p.Library, false), }) } } else { @@ -145,7 +166,7 @@ func listLibrariesHandler(svc *service.Container) gin.HandlerFunc { if counts != nil { total = counts[l.ID] } - out = append(out, webLibraryPayload{Library: l, Total: total}) + out = append(out, webLibraryPayload{Library: l, Total: total, CoverImageURL: effectiveLibraryCoverURL(l, false)}) } } // 精确指定目标库时,如果目标全是本地库,就不必枚举远程挂载。 @@ -272,9 +293,9 @@ func getLibraryHandler(svc *service.Container) gin.HandlerFunc { c.JSON(http.StatusNotFound, gin.H{"error": "not found"}) return } - c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0]}) + c.JSON(http.StatusOK, webLibraryPayload{Library: libs[0], CoverImageURL: effectiveLibraryCoverURL(libs[0], false)}) } else { - c.JSON(http.StatusOK, webLibraryPayload{Library: *lib}) + c.JSON(http.StatusOK, webLibraryPayload{Library: *lib, CoverImageURL: effectiveLibraryCoverURL(*lib, false)}) } } } diff --git a/internal/handler/routes_authenticated_core.go b/internal/handler/routes_authenticated_core.go index 74e6356..7aee977 100644 --- a/internal/handler/routes_authenticated_core.go +++ b/internal/handler/routes_authenticated_core.go @@ -36,6 +36,9 @@ func registerAuthedLibraryRoutes(authed *gin.RouterGroup, svc *service.Container authed.GET("/libraries", listLibrariesHandler(svc)) authed.POST("/libraries", middleware.AdminRequired(), createLibraryHandler(svc)) authed.GET("/libraries/:id", getLibraryHandler(svc)) + // 服务端生成的媒体库封面(成员海报拼图),Web / App / Emby 共用同一份。 + authed.GET("/libraries/:id/cover", libraryCoverHandler(svc)) + authed.HEAD("/libraries/:id/cover", libraryCoverHandler(svc)) authed.PATCH("/libraries/:id", middleware.AdminRequired(), updateLibraryHandler(svc)) authed.PUT("/libraries/reorder", middleware.AdminRequired(), reorderLibrariesHandler(svc)) authed.DELETE("/libraries/:id", middleware.AdminRequired(), deleteLibraryHandler(svc)) diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go index c2c37d2..8769e14 100644 --- a/internal/service/cache_cleanup.go +++ b/internal/service/cache_cleanup.go @@ -74,22 +74,20 @@ type ImageCacheScope struct { DropUnknownResizeVariants bool } -// ImageCachePool 描述一个图片缓存池:范围 + 自身配额 + 保留时长。 +// ImageCachePool 描述一个图片缓存池:范围 + 自身配额。 // // 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料, // 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。 -// 因此原图配小配额、短保留,总量超限时也优先淘汰原图。 +// 因此原图配小配额,总量超限时也优先淘汰原图。 +// +// 淘汰只看占用(配额头寸),不按文件年龄删除:缩略图生成成本高,只要磁盘还 +// 放得下就该留着,让再次浏览直接命中缓存。 type ImageCachePool struct { Name string Scopes []ImageCacheScope - MaxBytes int64 // 0 = 不单独限制 - MaxAge time.Duration // 0 = 不按时间淘汰 + MaxBytes int64 // 0 = 不单独限制 } -// imageFailMarkerMaxAge 是失败标记文件的保留时长:标记只用于观测/重试, -// 过期即视为噪音清掉(不会因为标记残留而阻止重新抓取)。 -const imageFailMarkerMaxAge = 24 * time.Hour - // tempImageCacheFile 报告文件名是否是下载/缩放过程中的临时文件。它们正在被 // 并发写入,清理时必须跳过。 func tempImageCacheFile(name string) bool { @@ -182,12 +180,14 @@ func removeImageCacheEntry(entry imageCacheFileEntry, result *PruneImageCacheRes _ = os.Remove(entry.path + ".fail") } -// PruneImageCachePools 按池清理图片缓存: -// 1. 删除超过池 MaxAge 的文件(原图按保留时长淘汰); -// 2. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发); -// 3. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件, +// PruneImageCachePools 按池清理图片缓存,只做占用淘汰: +// 1. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发); +// 2. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件, // 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。 // +// 不按文件年龄删除:过期即删会让一张几天前看过的海报重新解码(2 核机器上 +// 每张约 0.2 秒),代价远高于占着那几十 KB 磁盘。 +// // 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。 func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageCacheResult, error) { var result PruneImageCacheResult @@ -207,16 +207,18 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC } result.RemainingBytes = result.TotalBytesBefore - // 过期的失败标记直接清掉:它们不计容量,只用于观测与重试。 - cutoffMarkers := time.Now().Add(-imageFailMarkerMaxAge) + // 失败标记不计入容量:只清掉对应缓存文件已经不存在的孤儿标记(它们只是 + // 观测/重试用的噪音,留着没有任何作用)。 for _, marker := range failMarkers { - if marker.modTime.Before(cutoffMarkers) { - _ = os.Remove(marker.path) + base := strings.TrimSuffix(marker.path, ".fail") + if _, err := os.Stat(base); err == nil { + continue } + _ = os.Remove(marker.path) } - now := time.Now() deleted := make(map[string]bool, len(entries)) + deleteEntry := func(entry imageCacheFileEntry) { if deleted[entry.path] { return @@ -225,20 +227,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC removeImageCacheEntry(entry, &result) } - // 1. 按池保留时长淘汰(原图池)。 - for i := range pools { - if pools[i].MaxAge <= 0 { - continue - } - cutoff := now.Add(-pools[i].MaxAge) - for _, entry := range entries { - if entry.pool == i && entry.modTime.Before(cutoff) { - deleteEntry(entry) - } - } - } - - // 2. 池自身配额。 + // 1. 池自身配额。 for i := range pools { maxBytes := pools[i].MaxBytes if maxBytes <= 0 { @@ -263,7 +252,7 @@ func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageC } } - // 3. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。 + // 2. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。 if totalBytes > 0 && result.RemainingBytes > totalBytes { target := totalBytes * 80 / 100 for _, entry := range evictionOrder(entries, deleted) { @@ -357,11 +346,10 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul // 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被 // 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。 // 池顺序即总量超限时的淘汰优先级。 -func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge time.Duration) []ImageCachePool { +func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool { originals := ImageCachePool{ Name: "originals", MaxBytes: originalsMaxBytes, - MaxAge: originalsMaxAge, Scopes: []ImageCacheScope{ {Root: imagesDir, Recursive: false}, // 旧版平铺布局 {Root: filepath.Join(imagesDir, imageOriginalCacheSubdir), Recursive: true}, diff --git a/internal/service/cache_cleanup_pools_test.go b/internal/service/cache_cleanup_pools_test.go index 66f5b07..8945aad 100644 --- a/internal/service/cache_cleanup_pools_test.go +++ b/internal/service/cache_cleanup_pools_test.go @@ -9,7 +9,7 @@ import ( "time" ) -// writePoolFile ????????????????????????? +// writePoolFile ??????????????? mtime ??????? func writePoolFile(t *testing.T, path string, size int, modTime time.Time) { t.Helper() if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { @@ -39,42 +39,38 @@ func mustExist(t *testing.T, path string) { } } -// currentVariantPath ????????????????..img?? -// ?????????????????????????? +// currentVariantPath ??????????????????? +// <64 ????>.xq.img func currentVariantPath(dir, keyChar string, width, height, quality int) string { key := strings.Repeat(keyChar, sha256HexLength) return filepath.Join(dir, fmt.Sprintf("%s.%dx%dq%d.img", key, width, height, quality)) } -// TestPruneImageCachePoolsOriginalTTL ???????????????????? -// ?? TTL ?????????????????????? -func TestPruneImageCachePoolsOriginalTTL(t *testing.T) { +// TestPruneImageCachePoolsKeepsOldFilesUnderQuota ???????????? +// ???????????????????????? / TTL ???? +func TestPruneImageCachePoolsKeepsOldFilesUnderQuota(t *testing.T) { imagesDir := t.TempDir() now := time.Now() - stale := filepath.Join(imagesDir, imageOriginalCacheSubdir, "stale-original") - fresh := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh-original") - thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80) - writePoolFile(t, stale, 400, now.Add(-8*24*time.Hour)) - writePoolFile(t, fresh, 400, now.Add(-1*time.Hour)) - // ??????????TTL ???? - writePoolFile(t, thumb, 400, now.Add(-8*24*time.Hour)) + oldOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "old-original") + oldThumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "b", 480, 600, 80) + writePoolFile(t, oldOriginal, 400, now.Add(-90*24*time.Hour)) + writePoolFile(t, oldThumb, 400, now.Add(-90*24*time.Hour)) - pools := ImageCachePools(imagesDir, 0, 7*24*time.Hour) + pools := ImageCachePools(imagesDir, 0) res, err := PruneImageCachePools(pools, 100*1024*1024) if err != nil { t.Fatalf("prune failed: %v", err) } - if res.DeletedFiles != 1 { - t.Fatalf("deleted files = %d, want 1", res.DeletedFiles) + if res.DeletedFiles != 0 { + t.Fatalf("deleted files = %d, want 0 (age must not trigger deletion)", res.DeletedFiles) } - mustNotExist(t, stale) - mustExist(t, fresh) - mustExist(t, thumb) + mustExist(t, oldOriginal) + mustExist(t, oldThumb) } // TestPruneImageCachePoolsEvictsOriginalsBeforeDerived ???????????? -// ???????????????????? +// ???????????????? func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) { imagesDir := t.TempDir() now := time.Now() @@ -82,13 +78,13 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) { original := filepath.Join(imagesDir, imageOriginalCacheSubdir, "original") rendition := filepath.Join(imagesDir, imageRenditionCacheSubdir, "rendition") thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80) - // ????????????????? + // ????????????????????????? writePoolFile(t, original, 1024*1024, now.Add(-72*time.Hour)) writePoolFile(t, rendition, 1024*1024, now.Add(-2*time.Hour)) writePoolFile(t, thumb, 1024*1024, now.Add(-1*time.Hour)) - pools := ImageCachePools(imagesDir, 0, 0) - // ?? 3MB????2.4MB -> ????? 1.92MB?? + pools := ImageCachePools(imagesDir, 0) + // ?? 3MB??? 2.4MB -> ??? 1.92MB ??? res, err := PruneImageCachePools(pools, 2*1024*1024+400*1024) if err != nil { t.Fatalf("prune failed: %v", err) @@ -104,8 +100,8 @@ func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) { } } -// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ???????????? -// ?????????????? +// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ??????????? +// ???????????? func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) { imagesDir := t.TempDir() now := time.Now() @@ -117,8 +113,8 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) { writePoolFile(t, newOriginal, 800, now.Add(-1*time.Hour)) writePoolFile(t, thumb, 800, now.Add(-2*time.Hour)) - // ???? 1000 ??????????1600 -> ????800????? 1 ???? - pools := ImageCachePools(imagesDir, 1000, 0) + // ???? 1000??? 1600 ?? -> ??? 800??? 1 ??????? + pools := ImageCachePools(imagesDir, 1000) res, err := PruneImageCachePools(pools, 0) if err != nil { t.Fatalf("prune failed: %v", err) @@ -131,24 +127,26 @@ func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) { mustExist(t, thumb) } -// TestPruneImageCachePoolsCleansFailMarkers ???????????????? -// ???????????????? +// TestPruneImageCachePoolsCleansFailMarkers ??????????????? +// ??????????????????????????????????? func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) { imagesDir := t.TempDir() now := time.Now() entry := filepath.Join(imagesDir, imageOriginalCacheSubdir, "entry") entryMarker := entry + ".fail" + kept := filepath.Join(imagesDir, imageOriginalCacheSubdir, "kept") + keptMarker := kept + ".fail" orphanMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "orphan.fail") - freshMarker := filepath.Join(imagesDir, imageOriginalCacheSubdir, "fresh.fail") - writePoolFile(t, entry, 900, now.Add(-10*time.Hour)) - writePoolFile(t, entryMarker, 35, now.Add(-10*time.Hour)) + writePoolFile(t, entry, 900, now.Add(-2*time.Hour)) + writePoolFile(t, entryMarker, 35, now.Add(-2*time.Hour)) + writePoolFile(t, kept, 300, now.Add(-1*time.Hour)) + writePoolFile(t, keptMarker, 35, now.Add(-1*time.Hour)) writePoolFile(t, orphanMarker, 35, now.Add(-48*time.Hour)) - writePoolFile(t, freshMarker, 35, now.Add(-1*time.Minute)) - // ???? 500????900 ????????????????? - pools := ImageCachePools(imagesDir, 500, 0) + // ???? 500??? 900+300=1200 ???? mtime ????? entry ? 400? + pools := ImageCachePools(imagesDir, 500) res, err := PruneImageCachePools(pools, 0) if err != nil { t.Fatalf("prune failed: %v", err) @@ -158,26 +156,27 @@ func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) { } mustNotExist(t, entry) mustNotExist(t, entryMarker) + mustExist(t, kept) + mustExist(t, keptMarker) mustNotExist(t, orphanMarker) - mustExist(t, freshMarker) } -// TestPruneImageCachePoolsSkipsTemporaryFiles ???????????????? +// TestPruneImageCachePoolsSkipsTemporaryFiles ?????????????? // ????????????????? func TestPruneImageCachePoolsSkipsTemporaryFiles(t *testing.T) { imagesDir := t.TempDir() tmp := filepath.Join(imagesDir, imageOriginalCacheSubdir, "img-1234.tmp") writePoolFile(t, tmp, 4096, time.Now().Add(-48*time.Hour)) - pools := ImageCachePools(imagesDir, 1, 0) + pools := ImageCachePools(imagesDir, 1) if _, err := PruneImageCachePools(pools, 1); err != nil { t.Fatalf("prune failed: %v", err) } mustExist(t, tmp) } -// TestPruneImageCachePoolsDropsLegacyResizeVariants ????????.img???? -// ????????????????????????????????????? +// TestPruneImageCachePoolsDropsLegacyResizeVariants ?????? .img ?? +// ??????????????????????????? func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) { imagesDir := t.TempDir() resized := filepath.Join(imagesDir, imageResizeCacheSubdir) @@ -188,7 +187,7 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) { writePoolFile(t, current, 400, time.Now()) writePoolFile(t, other, 400, time.Now()) - pools := ImageCachePools(imagesDir, 0, 0) + pools := ImageCachePools(imagesDir, 0) res, err := PruneImageCachePools(pools, 0) if err != nil { t.Fatalf("prune failed: %v", err) @@ -201,8 +200,8 @@ func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) { } } -// TestPruneImageCachePoolsCountsLegacyFlatLayout ??????images/ ???? -// ????????????????? +// TestPruneImageCachePoolsCountsLegacyFlatLayout ???????images/ ??? +// ??????????????????????? func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) { imagesDir := t.TempDir() legacy := filepath.Join(imagesDir, "legacy-flat-cache") @@ -210,7 +209,8 @@ func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) { writePoolFile(t, legacy, 900, time.Now().Add(-30*24*time.Hour)) writePoolFile(t, thumb, 900, time.Now().Add(-30*24*time.Hour)) - pools := ImageCachePools(imagesDir, 0, 24*time.Hour) + // ???? 500?????????????????????????? + pools := ImageCachePools(imagesDir, 500) res, err := PruneImageCachePools(pools, 0) if err != nil { t.Fatalf("prune failed: %v", err) diff --git a/internal/service/emby_cache_invalidate_test.go b/internal/service/emby_cache_invalidate_test.go new file mode 100644 index 0000000..bb53e99 --- /dev/null +++ b/internal/service/emby_cache_invalidate_test.go @@ -0,0 +1,254 @@ +package service + +import ( + "context" + "fmt" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/glebarez/sqlite" + "go.uber.org/zap" + "gorm.io/gorm" + "gorm.io/gorm/logger" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// resetEmbyInvalThrottle 清掉全局失效节流,让测试能断言「这一次调用到底有 +// 没有真的清缓存」。测试结束后恢复原值,避免影响同包其他用例。 +func resetEmbyInvalThrottle(t *testing.T) { + t.Helper() + embyInvalMu.Lock() + previous := embyInvalLast + embyInvalLast = time.Time{} + embyInvalMu.Unlock() + t.Cleanup(func() { + embyInvalMu.Lock() + embyInvalLast = previous + embyInvalMu.Unlock() + }) +} + +func newCachedTestEmbyService(t *testing.T) *EmbyService { + t.Helper() + svc := newTestEmbyService(t) + cache := NewRuntimeCacheService(&config.Config{}, zap.NewNop()) + t.Cleanup(func() { _ = cache.Close() }) + return svc.SetRuntimeCache(cache) +} + +// TestRecordProgressKeepsEmbyPayloadCache 覆盖首页加载慢的核心回归点: +// 播放期间客户端每 5-10 秒上报一次进度,如果每次都清空 media:emby:*, +// 客户端刷新首页时 73 个媒体库的 Latest 会全部回源重建(生产环境实测 +// 首批 8-25s)。普通进度上报必须保留载荷缓存;只有「已看完」翻转才失效。 +func TestRecordProgressKeepsEmbyPayloadCache(t *testing.T) { + svc := newCachedTestEmbyService(t) + const mediaID = "ep-1" + media := model.Media{ + Base: model.Base{ID: mediaID}, + Title: "第 1 集", + Path: `/media/tv/Show/Season 01/S01E01.mkv`, + DurationSec: 100, + } + if err := svc.repo.DB.Create(&media).Error; err != nil { + t.Fatalf("create media: %v", err) + } + + key := svc.embyLatestCacheKey("user-1", "", 16) + svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{ + Items: []map[string]any{{"Id": "cached-item"}}, + }, time.Minute, time.Hour) + + resetEmbyInvalThrottle(t) + // 60s / 100s:播放中途 + if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 600_000_000, 1_000_000_000, "session-1"); err != nil { + t.Fatalf("record progress: %v", err) + } + if _, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key); !ok || stale { + t.Fatal("普通进度上报不应让 Emby 列表载荷缓存失效") + } + + resetEmbyInvalThrottle(t) + // 98s / 100s:进入片尾 30s 内的「已看完」区间 + if err := svc.RecordProgressWithSession(t.Context(), "user-1", mediaID, 980_000_000, 1_000_000_000, "session-2"); err != nil { + t.Fatalf("record completed progress: %v", err) + } + if _, _, ok := svc.cachedLatestItemsWithStale(t.Context(), key); ok { + t.Fatal("看完状态翻转后应让 Emby 列表载荷缓存失效") + } +} + +// TestFindSeriesGroupCachesSiblingVirtualSeries 覆盖 N+1 回归点:一次全表 +// 扫描算出了整库的剧集分组,若只缓存命中的那一部,同一页的 16 张卡片和 +// 16 张海报会各自重扫一遍 media 表。批量记忆后同批分组都应可命中。 +func TestFindSeriesGroupCachesSiblingVirtualSeries(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + + shows := []string{"Alpha", "Bravo", "Charlie"} + rows := make([]model.Media, 0, len(shows)*2) + for _, show := range shows { + for ep := 1; ep <= 2; ep++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: show + "-" + strconv.Itoa(ep)}, + LibraryID: lib.ID, + Title: show, + Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep), + SeasonNum: 1, + EpisodeNum: ep, + }) + } + } + for i := range rows { + if err := svc.repo.DB.Create(&rows[i]).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + + seriesIDs := map[string]string{} + for i := range rows { + if rows[i].EpisodeNum == 1 { + seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i]) + } + } + target, ok := seriesIDs["Alpha"] + if !ok { + t.Fatal("missing fixture series") + } + if _, found, err := svc.findSeriesGroup(t.Context(), target, ""); err != nil || !found { + t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", target, found, err) + } + + for show, id := range seriesIDs { + if show == "Alpha" { + continue + } + if _, cached := svc.cachedSeriesGroup(id); !cached { + t.Fatalf("同一次扫描算出的 %s 应一并进入缓存,否则每张卡片/海报都会重扫整库", show) + } + } +} + +// TestLatestPayloadKeepsStaleCopyForIdleClients 保证「最新添加」载荷在新鲜期 +// 之后仍有可用的陈旧副本:客户端空闲很久后再打开首页时,先拿陈旧结果并由 +// 后台重建,而不是让全部媒体库同时回源。 +func TestLatestPayloadKeepsStaleCopyForIdleClients(t *testing.T) { + svc := newCachedTestEmbyService(t) + key := svc.embyLatestCacheKey("user-1", "", 16) + svc.cache.SetJSONWithStale(t.Context(), key, embyLatestCacheValue{ + Items: []map[string]any{{"Id": "stale-item"}}, + }, time.Nanosecond, embyLatestStaleWindow) + time.Sleep(2 * time.Millisecond) + + items, stale, ok := svc.cachedLatestItemsWithStale(t.Context(), key) + if !ok || !stale { + t.Fatalf("expected a stale hit, got ok=%v stale=%v", ok, stale) + } + if len(items) != 1 || items[0]["Id"] != "stale-item" { + t.Fatalf("unexpected stale payload: %#v", items) + } +} + +// mediaScanCounter 统计真正落到 media 表上的查询次数,用来断言「整库数据 +// 只扫一次」:GORM 的 Trace 回调里能拿到最终 SQL。 +type mediaScanCounter struct { + logger.Interface + mu sync.Mutex + scans int +} + +func (c *mediaScanCounter) Trace(ctx context.Context, begin time.Time, fc func() (string, int64), err error) { + sql, _ := fc() + if strings.Contains(sql, "FROM `media`") { + c.mu.Lock() + c.scans++ + c.mu.Unlock() + } +} + +func (c *mediaScanCounter) Scans() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.scans +} + +// TestVirtualSeriesLookupsShareOneScan 覆盖冷启动风暴的回归点:客户端刷新 +// 首页时 73 个库的卡片和海报各自要一个虚拟 series 分组。修好之后,第一次 +// 扫描把整库分组都写进缓存,同一页剩下的查询都不该再落 SQL;随后一个不存在 +// 的虚拟 ID 也不该反复触发整库重扫。 +func TestVirtualSeriesLookupsShareOneScan(t *testing.T) { + counter := &mediaScanCounter{Interface: logger.Discard} + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: counter}) + if err != nil { + t.Fatalf("open db: %v", err) + } + if sqlDB, err := db.DB(); err == nil { + sqlDB.SetMaxOpenConns(1) + t.Cleanup(func() { _ = sqlDB.Close() }) + } + if err := db.AutoMigrate(model.AllModels()...); err != nil { + t.Fatalf("migrate: %v", err) + } + svc := NewEmbyService(&config.Config{}, zap.NewNop(), repository.New(db)) + + lib := model.Library{Name: "剧集", Path: `/media/tv`, Type: "tv", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + shows := []string{"Alpha", "Bravo", "Charlie"} + rows := make([]model.Media, 0, len(shows)*2) + for _, show := range shows { + for ep := 1; ep <= 2; ep++ { + rows = append(rows, model.Media{ + Base: model.Base{ID: show + "-" + strconv.Itoa(ep)}, + LibraryID: lib.ID, + Title: show, + Path: fmt.Sprintf("/media/tv/%s/Season 01/S01E%02d.mkv", show, ep), + SeasonNum: 1, + EpisodeNum: ep, + }) + } + } + for i := range rows { + if err := svc.repo.DB.Create(&rows[i]).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + seriesIDs := map[string]string{} + for i := range rows { + if rows[i].EpisodeNum == 1 { + seriesIDs[rows[i].Title] = svc.seriesIDForMedia(t.Context(), &rows[i]) + } + } + + if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs["Alpha"], ""); err != nil || !found { + t.Fatalf("findSeriesGroup(Alpha) = (%v, %v), want found", found, err) + } + if got := counter.Scans(); got != 1 { + t.Fatalf("首次查询应恰好扫描 media 一次,实际 %d 次", got) + } + + for _, show := range []string{"Bravo", "Charlie"} { + if _, found, err := svc.findSeriesGroup(t.Context(), seriesIDs[show], ""); err != nil || !found { + t.Fatalf("findSeriesGroup(%s) = (%v, %v), want found", show, found, err) + } + } + if got := counter.Scans(); got != 1 { + t.Fatalf("同批分组应命中缓存,不应再扫 media,实际累计 %d 次", got) + } + + if _, found, err := svc.findSeriesGroup(t.Context(), embyVirtualSeriesPrefix+"missing", ""); err != nil || found { + t.Fatalf("未知虚拟 ID 应为 not found,实际 found=%v err=%v", found, err) + } + if got := counter.Scans(); got != 1 { + t.Fatalf("刚扫过整库后,未知 ID 不应重扫 media,实际累计 %d 次", got) + } +} diff --git a/internal/service/emby_compat.go b/internal/service/emby_compat.go index 15fe4e5..67cea80 100644 --- a/internal/service/emby_compat.go +++ b/internal/service/emby_compat.go @@ -56,6 +56,17 @@ type EmbyService struct { virtualSeries map[string]embySeriesCacheEntry virtualSeasons map[string]embySeasonCacheEntry virtualArtwork map[string]embyArtworkCacheEntry + // virtualGroups 把「整库虚拟剧集分组」的扫描折叠成一次。 + // + // 客户端刷新首页时会同时请求 73 个库的 Latest,每个库的卡片和海报都要 + // 各自的虚拟 series 分组。不同剧的分组缓存键不同,singleflight 键也不同, + // 于是第一波请求会并发重扫整张 media 表(生产环境实测冷启动 14-17s)。 + // 按用户折叠成一次扫描后,同一波请求共享结果并整批进缓存。 + virtualGroups singleflight.Group + // virtualScanAt 记录每个用户最近一次整库扫描的时间,用于避免为同一个 + // 不存在的 id 反复重扫(必须按用户区分:可见性不同,别人的快照不能当 + // 自己的答案)。由 virtualMu 保护。 + virtualScanAt map[string]time.Time visibilityMu sync.RWMutex visibilityCache map[string]embyVisibilityCacheEntry @@ -186,6 +197,15 @@ const ( embyVirtualSeriesCap = 8000 embyVirtualSeasonCap = 16000 embyVirtualArtworkCap = 24000 + // embySeriesGroupBulkCacheEpisodes 限制一次批量记忆的剧集行数。 + // + // 批量记忆把「一次扫描服务整页卡片 + 整页海报」的收益拿满,但同时让整库 + // 剧集常驻内存。超过该量级时退回只缓存命中的那一部,避免大库吃光内存。 + embySeriesGroupBulkCacheEpisodes = 8000 + // embyVirtualScanGrace 是「刚做过整库虚拟剧集扫描」的保护窗口。窗口内再 + // 请求一个不在缓存里的虚拟 series ID,直接按不存在处理,避免为同一个 + // 未知 id 反复重扫整库。 + embyVirtualScanGrace = 10 * time.Second // Clients that already cached the 1x1 placeholder treat a stable tag as // immutable. Virtual ids are the ones that served that placeholder, so // only those tags get a suffix that forces a refetch. diff --git a/internal/service/emby_items_cache.go b/internal/service/emby_items_cache.go index 83b5716..cd64a23 100644 --- a/internal/service/emby_items_cache.go +++ b/internal/service/emby_items_cache.go @@ -6,6 +6,7 @@ import ( "sort" "strconv" "strings" + "time" ) type embyItemsCacheValue struct { @@ -77,7 +78,15 @@ func (e *EmbyService) embySimilarCacheKey(mediaID, userID string, limit int) str } // defaultEmbyLatestCacheTTLSeconds 是 Emby「最新添加」缓存的兜底时长。 -const defaultEmbyLatestCacheTTLSeconds = 300 +const defaultEmbyLatestCacheTTLSeconds = 900 + +// embyLatestStaleWindow 是「最新添加」载荷在进程内保留的陈旧副本时长。 +// +// 客户端空闲很久之后再打开首页时,新鲜期早已过去:生产环境观察到这时 +// 73 个媒体库的 Latest 同时回源,首批请求要 8-25s 才回来,客户端等不及 +// 还会重试,进一步放大压力。保留陈旧副本后,这类请求立刻拿到上次的结果, +// 由后台异步重建(见 refreshLatestItemsAsync),用户不再等 SQL。 +const embyLatestStaleWindow = 24 * time.Hour // embyLatestCacheTTLSeconds 返回「最新添加」列表的缓存时长。它刻意比通用 // 媒体缓存更长:客户端刷新首页时会同时请求全部媒体库的 Latest(生产环境 diff --git a/internal/service/emby_items_detail.go b/internal/service/emby_items_detail.go index 040745e..d6f4e44 100644 --- a/internal/service/emby_items_detail.go +++ b/internal/service/emby_items_detail.go @@ -153,7 +153,7 @@ func (e *EmbyService) loadLatestItemsCached(ctx context.Context, userID, parentI } if e.cache != nil { freshTTL := time.Duration(e.embyLatestCacheTTLSeconds()) * time.Second - e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+30*time.Minute) + e.cache.SetJSONWithStale(loadCtx, cacheKey, value, freshTTL, freshTTL+embyLatestStaleWindow) } e.rememberArtworkRefs(value.Artwork) return value, nil diff --git a/internal/service/emby_series.go b/internal/service/emby_series.go index 8e8e537..e49a633 100644 --- a/internal/service/emby_series.go +++ b/internal/service/emby_series.go @@ -41,47 +41,65 @@ func (e *EmbyService) findSeriesGroup(ctx context.Context, id, userID string) (e return embySeriesGroup{}, false, nil } if strings.HasPrefix(id, embyVirtualSeriesPrefix) { - if group, ok := e.cachedSeriesGroup(id); ok { - return group, true, nil - } + return e.findVirtualSeriesGroup(ctx, id, userID) } var rows []model.Media q := e.repo.DB.WithContext(ctx).Model(&model.Media{}).Where("season_num > 0 OR episode_num > 0") q = e.applyUserMediaVisibility(ctx, q, userID) - if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { - q = q.Where("series_id = ?", id) - } else { - // 虚拟 series ID 只可能来自 series_id 为空的媒体: - // 有 series_id 时分组 key 就是 series_id 本身(UUID,不带虚拟前缀)。 - q = q.Where("series_id IS NULL OR series_id = ''") - } + q = q.Where("series_id = ?", id) if err := q.Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc").Limit(embySeriesGroupingLimit).Find(&rows).Error; err != nil { return embySeriesGroup{}, false, err } - for _, group := range e.seriesGroupsFromMedia(ctx, rows) { + groups := e.seriesGroupsFromMedia(ctx, rows) + bulkCached := e.rememberScannedSeriesGroups(groups) + for _, group := range groups { if group.ID == id { - e.rememberSeriesGroup(group) + if !bulkCached { + e.rememberSeriesGroup(group) + } return group, true, nil } } - if !strings.HasPrefix(id, embyVirtualSeriesPrefix) { - if series, err := e.repo.Series.FindByID(ctx, id); err != nil { - return embySeriesGroup{}, false, err - } else if series != nil { - return embySeriesGroup{ - ID: series.ID, - LibraryID: series.LibraryID, - Name: series.Title, - PosterURL: series.PosterURL, - BackdropURL: series.BackdropURL, - Overview: series.Overview, - Rating: series.Rating, - Year: series.Year, - TMDbID: series.TMDbID, - BangumiID: series.BangumiID, - CreatedAt: series.CreatedAt, - DateLastMediaAdded: series.CreatedAt, - }, true, nil + if series, err := e.repo.Series.FindByID(ctx, id); err != nil { + return embySeriesGroup{}, false, err + } else if series != nil { + return embySeriesGroup{ + ID: series.ID, + LibraryID: series.LibraryID, + Name: series.Title, + PosterURL: series.PosterURL, + BackdropURL: series.BackdropURL, + Overview: series.Overview, + Rating: series.Rating, + Year: series.Year, + TMDbID: series.TMDbID, + BangumiID: series.BangumiID, + CreatedAt: series.CreatedAt, + DateLastMediaAdded: series.CreatedAt, + }, true, nil + } + return embySeriesGroup{}, false, nil +} + +// findVirtualSeriesGroup 处理 series_id 为空的剧集分组(虚拟 series ID)。 +// +// 虚拟 ID 是 hash(库名 + 剧名),无法反解,只能整库算出分组后按 ID 取用。 +// 这里走 loadVirtualSeriesGroups,把同一用户的并发请求折叠成一次扫描: +// 首页一次刷新会同时要 73 个库的卡片和海报,各扫一遍在 2 核主机上要 14-17s。 +func (e *EmbyService) findVirtualSeriesGroup(ctx context.Context, id, userID string) (embySeriesGroup, bool, error) { + if group, ok := e.cachedSeriesGroup(id); ok { + return group, true, nil + } + if e.virtualScanRecently(userID) { + return embySeriesGroup{}, false, nil + } + groups, err := e.loadVirtualSeriesGroups(ctx, userID) + if err != nil { + return embySeriesGroup{}, false, err + } + for _, group := range groups { + if group.ID == id { + return group, true, nil } } return embySeriesGroup{}, false, nil @@ -103,20 +121,14 @@ func (e *EmbyService) findSeasonGroup(ctx context.Context, id, userID string) (e return season, true, nil } // 回退:未刮削(series_id 为空,虚拟 key 由库名+名称派生)的媒体只能全量分组。 - var rows []model.Media - q := e.repo.DB.WithContext(ctx).Model(&model.Media{}). - Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)") - q = e.applyUserMediaVisibility(ctx, q, userID) - if err := q. - Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc"). - Limit(embySeriesGroupingLimit). - Find(&rows).Error; err != nil { + // 复用共享扫描:首页并发刷新时整库只扫一次。 + groups, err := e.loadVirtualSeriesGroups(ctx, userID) + if err != nil { return embySeasonGroup{}, false, err } - for _, series := range e.seriesGroupsFromMedia(ctx, rows) { + for _, series := range groups { for _, season := range e.seasonsForSeries(series) { if season.ID == id { - e.rememberSeriesGroup(series) return season, true, nil } } @@ -186,6 +198,84 @@ func (e *EmbyService) seasonGroupForSeries(ctx context.Context, id, seriesID, us return embySeasonGroup{}, false, nil } +// rememberScannedSeriesGroups 批量记住一次扫描得到的全部分组,返回是否真的 +// 批量写入(超过剧集预算时不做,调用方需自行缓存命中的那一部)。 +// +// 只缓存命中的一部时,同一部剧的多张卡片/海报仍要各扫一次;整批写入后, +// 同一次扫描算出来的其他剧也直接命中,于是整页 16 张卡片 + 16 张海报共用 +// 一次全表扫描。 +func (e *EmbyService) rememberScannedSeriesGroups(groups []embySeriesGroup) bool { + if e == nil || len(groups) == 0 { + return false + } + episodes := 0 + for i := range groups { + episodes += len(groups[i].Episodes) + if episodes > embySeriesGroupBulkCacheEpisodes { + return false + } + } + e.rememberSeriesGroups(groups) + return true +} + +// loadVirtualSeriesGroups 返回 series_id 为空的整库剧集分组。 +// +// 同一用户的并发调用共享同一次扫描:客户端刷新首页时 73 个库的 Latest 同时 +// 进来,每个库的卡片和海报又各自要自己的虚拟 series 分组。分组缓存键各不 +// 相同,singleflight 也就不会合并,于是第一波请求把整张 media 表并发重算 +// 了 73 次(生产环境实测 14-17s)。这里按用户折叠成一次,结果整批进缓存, +// 调用方拿到切片后按 ID 取用即可。 +func (e *EmbyService) loadVirtualSeriesGroups(ctx context.Context, userID string) ([]embySeriesGroup, error) { + if e == nil || e.repo == nil { + return nil, nil + } + v, err, _ := e.virtualGroups.Do("virtual:"+userID, func() (any, error) { + // 与 loadLatestItemsCached 一致:一个客户端断开不应取消正在为其他 + // 客户端填充的共享扫描。 + loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + var rows []model.Media + q := e.repo.DB.WithContext(loadCtx).Model(&model.Media{}). + Where("(series_id IS NULL OR series_id = '') AND (season_num > 0 OR episode_num > 0)") + q = e.applyUserMediaVisibility(loadCtx, q, userID) + if err := q. + Order("media.season_num asc, media.episode_num asc, media.episode_fraction asc, media.created_at asc"). + Limit(embySeriesGroupingLimit). + Find(&rows).Error; err != nil { + return nil, err + } + groups := e.seriesGroupsFromMedia(loadCtx, rows) + e.rememberSeriesGroups(groups) + e.markVirtualScan(userID) + return groups, nil + }) + if err != nil { + return nil, err + } + groups, _ := v.([]embySeriesGroup) + return groups, nil +} + +// markVirtualScan 记录该用户最近一次整库扫描的时间。 +func (e *EmbyService) markVirtualScan(userID string) { + e.virtualMu.Lock() + if e.virtualScanAt == nil { + e.virtualScanAt = make(map[string]time.Time, 4) + } + e.virtualScanAt[userID] = time.Now() + e.virtualMu.Unlock() +} + +// virtualScanRecently 报告该用户是否刚做过一次整库扫描。窗口内仍不在缓存里 +// 的目标 ID 按不存在处理,避免为同一个未知 ID 反复重扫整库。 +func (e *EmbyService) virtualScanRecently(userID string) bool { + e.virtualMu.RLock() + scannedAt := e.virtualScanAt[userID] + e.virtualMu.RUnlock() + return !scannedAt.IsZero() && time.Since(scannedAt) < embyVirtualScanGrace +} + func (e *EmbyService) seriesGroupsFromMedia(ctx context.Context, rows []model.Media) []embySeriesGroup { byID := map[string]*embySeriesGroup{} order := []string{} diff --git a/internal/service/emby_series_cache.go b/internal/service/emby_series_cache.go index 7e08e83..cb5adc5 100644 --- a/internal/service/emby_series_cache.go +++ b/internal/service/emby_series_cache.go @@ -26,19 +26,45 @@ func (e *EmbyService) rememberSeriesGroup(group embySeriesGroup) { if e == nil || strings.TrimSpace(group.ID) == "" { return } + e.rememberSeriesGroups([]embySeriesGroup{group}) +} + +// rememberSeriesGroups 批量记住一组剧集分组(含派生季与封面)。 +// +// 一次全表扫描会同时算出整库的剧集分组,而调用方往往只关心其中一部。若只 +// 逐个 rememberSeriesGroup,同一次首页刷新里 16 张卡片 + 16 张海报都要各自 +// 命中自己的虚拟 series ID,未命中就重扫整张 media 表(生产环境实测单次 +// 1.3s,73 个库并发时把连接池排满,首页要 8-25s)。批量写入后冷缓存只付 +// 一次扫描,其余全部是 O(1) 命中。 +func (e *EmbyService) rememberSeriesGroups(groups []embySeriesGroup) { + if e == nil || len(groups) == 0 { + return + } expiresAt := time.Now().Add(embyVirtualCacheTTL) e.virtualMu.Lock() defer e.virtualMu.Unlock() if e.virtualSeries == nil { - e.virtualSeries = make(map[string]embySeriesCacheEntry) + e.virtualSeries = make(map[string]embySeriesCacheEntry, len(groups)) } if e.virtualSeasons == nil { - e.virtualSeasons = make(map[string]embySeasonCacheEntry) + e.virtualSeasons = make(map[string]embySeasonCacheEntry, len(groups)) } if e.virtualArtwork == nil { - e.virtualArtwork = make(map[string]embyArtworkCacheEntry) + e.virtualArtwork = make(map[string]embyArtworkCacheEntry, len(groups)*2) } + for _, group := range groups { + e.rememberSeriesGroupLocked(group, expiresAt) + } + // 写入后再裁剪:逐条裁剪会让整批写入退化成 O(n²),且中途裁剪可能把 + // 同一批里刚写进去的分组又淘汰掉。 e.trimVirtualCachesLocked(time.Now()) +} + +// rememberSeriesGroupLocked 写入单个分组,调用方必须已持有 virtualMu。 +func (e *EmbyService) rememberSeriesGroupLocked(group embySeriesGroup, expiresAt time.Time) { + if strings.TrimSpace(group.ID) == "" { + return + } e.virtualSeries[group.ID] = embySeriesCacheEntry{group: group, expiresAt: expiresAt} e.virtualArtwork[group.ID] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt} e.virtualArtwork[group.ID+"-bd"] = embyArtworkCacheEntry{primary: group.PosterURL, backdrop: group.BackdropURL, expiresAt: expiresAt} diff --git a/internal/service/emby_system.go b/internal/service/emby_system.go index c416f37..7234139 100644 --- a/internal/service/emby_system.go +++ b/internal/service/emby_system.go @@ -2,6 +2,9 @@ package service import ( "context" + "crypto/sha256" + "encoding/hex" + "io" "sort" "strings" "time" @@ -278,6 +281,20 @@ func isSupportedEmbyCollectionType(t string) bool { return false } +// libraryCoverTag 生成媒体库拼图封面的图片版本号。 +// +// 第三方客户端把库主图按 (URL, ImageTag) 缓存,tag 不变就不会重新请求。 +// 用成员海报列表的指纹做 tag,库内容变化或生成规则调整时客户端自然会刷新。 +func libraryCoverTag(libraryID string, sources []string) string { + h := sha256.New() + _, _ = io.WriteString(h, libraryID) + for _, src := range sources { + _, _ = io.WriteString(h, src) + _, _ = h.Write([]byte{0}) + } + return libraryID + "-" + hex.EncodeToString(h.Sum(nil))[:8] +} + func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[string]any { collectionType := "movies" switch l.Type { @@ -292,9 +309,17 @@ func (e *EmbyService) libraryAsView(ctx context.Context, l *model.Library) map[s } // 库封面:与剧集/电影一样,只有在能解析出封面时才广告 ImageTags.Primary, // 否则客户端会认为该库无主图而不去请求 /Images/Primary。 + // + // 拼图封面的 tag 带上「成员海报」的指纹:库内容变化或生成规则调整(例如 + // 从 2×2 改成竖切)时 tag 会变,客户端才会丢缓存重新拉图;固定用库 ID + // 会让第三方客户端永远显示第一次缓存下来的封面。 imageTags := map[string]string{} - if e.LibraryHasCover(ctx, l.ID) { - imageTags["Primary"] = l.ID + if sources, lib := LibraryCoverSourcesForLibrary(ctx, e.repo, l.ID); lib != nil { + if len(sources) > 0 { + imageTags["Primary"] = libraryCoverTag(l.ID, sources) + } else if strings.TrimSpace(lib.CoverURL) != "" { + imageTags["Primary"] = l.ID + } } return map[string]any{ "Id": l.ID, diff --git a/internal/service/emby_user_data.go b/internal/service/emby_user_data.go index 6c3c981..1dfc35c 100644 --- a/internal/service/emby_user_data.go +++ b/internal/service/emby_user_data.go @@ -136,7 +136,12 @@ func (e *EmbyService) RecordProgressWithSession(ctx context.Context, userID, med SessionID: playSessionID, SessionStartedAtMs: embyPlaySessionStartedAtMs(playSessionID), }) - if err == nil { + // 普通进度上报不清列表载荷缓存。播放期间客户端每 5-10 秒上报一次, + // 每次清空 media:emby:* 会让「最新添加」整批 payload(客户端刷新首页时 + // 一次并发 73 个库)反复回源 SQL,生产环境实测因此出现 8-25s 的加载。 + // 续播位置由 /Items/Resume 与条目详情实时查库,列表里的进度条允许滞后; + // 只有「已看完」这类状态翻转才让缓存失效(position 到达片尾 30s 内)。 + if err == nil && playbackProgressCompleted(pos, dur) { e.invalidateEmbyItemsCache(ctx) } return err @@ -287,10 +292,11 @@ func applyMeBoxUserData(raw any, history *model.PlaybackHistory, favorite bool) return userData } -// embyInvalMu 节流全量缓存失效:播放期间客户端每 5-10s 上报一次进度, -// 每次都 SCAN+DEL 全部 media:emby:* 缓存会把缓存命中率持续打穿(其他 -// 客户端每次翻页都回源 SQL)。条目载荷的 UserData 在请求时动态合并, -// 进度类变更做 30s 节流即可,不影响正确性观感。 +// embyInvalMu 节流全量缓存失效:收藏/标记已看这类显式操作会改变列表里的 +// UserData,必须让 payload 失效。客户端连续点选时会短时间触发多次,节流到 +// 30s 一次即可,避免每次都 SCAN+DEL 全部 media:emby:* 缓存。 +// 播放进度上报不走这里(见 RecordProgressWithSession):续播位置实时查库, +// 列表里的进度条允许滞后,不值得为它把整批首页载荷打回源 SQL。 var ( embyInvalMu sync.Mutex embyInvalLast time.Time diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index 805398e..c457957 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -200,7 +200,7 @@ func imageResizeConcurrency() int { } // Prune removes oldest cached images until disk usage is within the configured limit. -// 原图池按独立配额与保留时长优先淘汰,客户端热路径读取的成品图最后淘汰。 +// 原图池按独立配额优先淘汰,客户端热路径读取的成品图最后淘汰。 func (p *ImageProxy) Prune() (PruneImageCacheResult, error) { if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 { return PruneImageCacheResult{}, nil @@ -209,11 +209,7 @@ func (p *ImageProxy) Prune() (PruneImageCacheResult, error) { if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 { originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024 } - var originalsMaxAge time.Duration - if p.cfg.Cache.ImagesOriginalsTTLHours > 0 { - originalsMaxAge = time.Duration(p.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour - } totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024 - pools := ImageCachePools(p.cacheDir, originalsMaxBytes, originalsMaxAge) + pools := ImageCachePools(p.cacheDir, originalsMaxBytes) return PruneImageCachePools(pools, totalBytes) } diff --git a/internal/service/library_cover.go b/internal/service/library_cover.go new file mode 100644 index 0000000..64f7973 --- /dev/null +++ b/internal/service/library_cover.go @@ -0,0 +1,564 @@ +package service + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + "image/jpeg" + "io" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" + + "golang.org/x/image/draw" + + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// 媒体库封面拼图(Emby 风格的「封面集」)。 +// +// Emby / Jellyfin 在没有管理员自定义封面时,会用库内几张海报拼成一张 +// 2×2 的合成图作为库主图(folder image)。MeBox 早先只是挑评分最高的 +// 那一张单图,于是每个库的封面看起来都像「随便选了一张」。这里按同样的 +// 思路生成拼图,供 Emby 客户端、Web 和 App 共用同一份图片。 + +const ( + // libraryCoverDefaultWidth / Height 是拼图输出的默认画布。 + // + // 库条目的 PrimaryImageAspectRatio 报的是 16:9(见 emby_system.go), + // 客户端按这个比例摆卡片;生成同样比例的图可以避免客户端再裁一刀。 + libraryCoverDefaultWidth = 480 + libraryCoverDefaultHeight = 270 + libraryCoverMinWidth = 96 + libraryCoverMaxWidth = 1920 + libraryCoverAspect = 16.0 / 9.0 + + // libraryCoverSourceLimit 是拼图实际取用的成员海报数上限。 + // + // 封面是竖切分栏:三栏在 16:9 画布上每栏接近 3:5,既保留海报主体,又能 + // 让一个库有多部作品可辨识;再多就会把海报裁成很窄的竖条。 + libraryCoverSourceLimit = 3 + // libraryCoverMaxPanels 是合成函数能接受的最多分栏数(更长的来源列表 + // 只取前几张)。留 4 是给「按库类型调栏数」留的余量。 + libraryCoverMaxPanels = 4 + // libraryCoverCandidatePool 是挑选海报时的候选池大小。只看最近的一批 + // 成员,避免为一个库封面扫全库;对「最新入库」的库来说这批就是它最好 + // 的那几张。 + libraryCoverCandidatePool = 200 + + libraryCoverQuality = 85 + libraryCoverSubdir = "library-covers" +) + +// libraryCoverSourceCache 缓存「库 → 成员海报来源」。Emby 客户端进入首页 +// 时会对每个库各请求一次主图,把这个查询压到每库 10 分钟一次,避免几十个 +// 200 行的列表查询同时打进来。 +var libraryCoverSourceCache = struct { + mu sync.Mutex + items map[string]libraryCoverSourceEntry +}{items: make(map[string]libraryCoverSourceEntry)} + +type libraryCoverSourceEntry struct { + sources []string + expiresAt time.Time +} + +const libraryCoverSourceTTL = 10 * time.Minute + +func cachedLibraryCoverSources(libraryID string) ([]string, bool) { + libraryCoverSourceCache.mu.Lock() + defer libraryCoverSourceCache.mu.Unlock() + entry, ok := libraryCoverSourceCache.items[libraryID] + if !ok || time.Now().After(entry.expiresAt) { + if ok { + delete(libraryCoverSourceCache.items, libraryID) + } + return nil, false + } + return entry.sources, true +} + +func rememberLibraryCoverSources(libraryID string, sources []string) { + libraryCoverSourceCache.mu.Lock() + defer libraryCoverSourceCache.mu.Unlock() + if len(libraryCoverSourceCache.items) > 4000 { + libraryCoverSourceCache.items = make(map[string]libraryCoverSourceEntry) + } + libraryCoverSourceCache.items[libraryID] = libraryCoverSourceEntry{ + sources: sources, + expiresAt: time.Now().Add(libraryCoverSourceTTL), + } +} + +// LibraryCoverSources 返回媒体库封面拼图要用的海报来源,最多 limit 张。 +// +// 选法与网页旧逻辑一致(库内最近一批成员,按 seriesArtworkScore 取质量 +// 最高者),额外按剧集去重,避免四张海报全是同一部剧的不同集。 +func LibraryCoverSources(ctx context.Context, repos *repository.Container, libraryID string, limit int) []string { + if repos == nil || repos.Media == nil || strings.TrimSpace(libraryID) == "" { + return nil + } + if limit <= 0 || limit > libraryCoverSourceLimit { + limit = libraryCoverSourceLimit + } + if cached, ok := cachedLibraryCoverSources(libraryID); ok { + if len(cached) > limit { + return cached[:limit] + } + return cached + } + rows, err := repos.Media.ListByLibrariesFilteredNoCount(ctx, []string{libraryID}, 0, libraryCoverCandidatePool, + repository.MediaQueryFilter{IncludeNSFW: true}) + if err != nil || len(rows) == 0 { + return nil + } + type candidate struct { + url string + key string + score int + } + candidates := make([]candidate, 0, len(rows)) + for i := range rows { + row := rows[i] + raw := strings.TrimSpace(row.PosterURL) + if raw == "" { + raw = strings.TrimSpace(row.BackdropURL) + } + if raw == "" { + continue + } + key := mediaSeriesKey(row) + if key == "" { + key = "id:" + row.ID + } + candidates = append(candidates, candidate{url: raw, key: key, score: seriesArtworkScore(row)}) + } + // 评分高的优先;同分保持数据库顺序(最近的在前),封面更贴近「最新入库」。 + sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score }) + + out := make([]string, 0, limit) + seenKey := make(map[string]struct{}, limit) + seenURL := make(map[string]struct{}, limit) + for _, item := range candidates { + if len(out) >= limit { + break + } + if _, ok := seenKey[item.key]; ok { + continue + } + if _, ok := seenURL[item.url]; ok { + continue + } + seenKey[item.key] = struct{}{} + seenURL[item.url] = struct{}{} + out = append(out, item.url) + } + if len(out) == 0 { + rememberLibraryCoverSources(libraryID, nil) + return nil + } + rememberLibraryCoverSources(libraryID, out) + return out +} + +// LibraryCoverCacheDir 返回拼图缓存目录(与图片代理的 images/ 分开存放, +// 不会被图片缓存的配额清理误删)。 +func LibraryCoverCacheDir(cfgCacheDir string) string { + base := strings.TrimSpace(cfgCacheDir) + if base == "" { + base = "./cache" + } + return filepath.Join(base, libraryCoverSubdir) +} + +// LibraryCoverImage 生成(或命中缓存)媒体库封面拼图。 +// +// width/height 是期望的输出画布;服务端会按 16:9 收敛,返回的图片不会 +// 超过该边界。sources 由调用方提供(见 LibraryCoverSources),这样 Emby +// 路由和 Web API 可以共用同一套生成与缓存逻辑。 +func (p *ImageProxy) LibraryCoverImage(ctx context.Context, libraryID string, sources []string, width, height int) ([]byte, error) { + if p == nil { + return nil, errors.New("image proxy unavailable") + } + libraryID = strings.TrimSpace(libraryID) + sources = normalizeCoverSources(sources) + if libraryID == "" || len(sources) == 0 { + return nil, errors.New("no library cover sources") + } + outW, outH := libraryCoverCanvas(width, height) + cachePath, sourceKey := p.libraryCoverCachePath(libraryID, sources, outW, outH) + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash. + return data, nil + } + + key := fmt.Sprintf("library-cover:%s-%dx%d-%s", libraryID, outW, outH, sourceKey) + v, err, _ := p.fetchGroup.Do(key, func() (any, error) { + if data, err := os.ReadFile(cachePath); err == nil && len(data) > 0 { // #nosec G304 -- cachePath is derived from a content hash. + return data, nil + } + data, buildErr := p.buildLibraryCover(ctx, libraryID, sources, outW, outH, cachePath, sourceKey) + if buildErr != nil { + return nil, buildErr + } + return data, nil + }) + if err != nil { + return nil, err + } + data, _ := v.([]byte) + if len(data) == 0 { + return nil, errors.New("empty library cover") + } + return data, nil +} + +func (p *ImageProxy) buildLibraryCover(ctx context.Context, libraryID string, sources []string, width, height int, cachePath, sourceKey string) ([]byte, error) { + images := p.fetchCoverSources(ctx, sources) + if len(images) == 0 { + return nil, errors.New("no library cover image fetched") + } + // 解码与合成是 CPU 密集操作:和缩略图共用同一组解码槽,避免一次首页 + // 刷新把 2 核主机打满。 + release, ok := p.acquireResizeSlotWithin(ctx, p.resizeQueueTimeout()) + if ok { + defer release() + } + data, err := composeLibraryCover(images, width, height) + if err != nil { + return nil, err + } + if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { + return data, nil + } + if err := writeFileAtomic(cachePath, data); err != nil { + p.warn("write library cover cache failed", err) + return data, nil + } + p.pruneLibraryCoverVariants(filepath.Dir(cachePath), libraryID, sourceKey) + return data, nil +} + +// fetchCoverSources 并发拉取成员海报(受图片代理自身的缓存/鉴权/SSRF 规则 +// 保护)。单张失败只丢弃该张,其余仍然可以拼图。 +func (p *ImageProxy) fetchCoverSources(ctx context.Context, sources []string) [][]byte { + out := make([][]byte, len(sources)) + var wg sync.WaitGroup + sem := make(chan struct{}, libraryCoverMaxPanels) + fetchCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + for i, raw := range sources { + i, raw := i, raw + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + data, err := p.coverSourceBytes(fetchCtx, raw) + if err != nil || len(data) == 0 { + return + } + out[i] = data + }() + } + wg.Wait() + compacted := make([][]byte, 0, len(out)) + for _, data := range out { + if len(data) > 0 { + compacted = append(compacted, data) + } + } + return compacted +} + +// coverSourceBytes 读取一张海报来源。 +// +// 刮削会把 sidecar 海报存成 /media/... 这类绝对路径,而 fetch 路径只接受 +// http(s)(validateURL 要求有 host),直接丢给 Fetch 会全部失败。本地路径 +// 按图片代理同一套 isAllowedLocalPath 白名单读盘,远程地址才走 Fetch, +// 这样鉴权、SSRF 校验与磁盘缓存的行为与 /api/img 完全一致。 +func (p *ImageProxy) coverSourceBytes(ctx context.Context, raw string) ([]byte, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("empty cover source") + } + if isLocalImagePath(raw) { + abs, err := filepath.Abs(filepath.Clean(raw)) + if err != nil { + return nil, err + } + if !p.isAllowedLocalPath(abs) { + return nil, errors.New("local cover path not allowed") + } + return os.ReadFile(abs) // #nosec G304 -- path validated against the library roots above. + } + data, _, err := p.Fetch(ctx, raw) + if err != nil { + return nil, err + } + return data, nil +} + +// libraryCoverCachePath 生成缓存路径。 +// +// 文件名里的 sourceKey 只由成员海报列表决定(不含尺寸):成员变化时换一批 +// 文件,同一批成员的多个尺寸变体可以共存,不会被互相删掉。size 单独进文件名, +// 让 Emby(按客户端 maxWidth/maxHeight)与 Web/App(固定档位)各用自己的成品。 +func (p *ImageProxy) libraryCoverCachePath(libraryID string, sources []string, width, height int) (string, string) { + h := sha256.New() + for _, src := range sources { + _, _ = io.WriteString(h, src) + _, _ = h.Write([]byte{0}) + } + sourceKey := hex.EncodeToString(h.Sum(nil))[:16] + name := fmt.Sprintf("%s-%dx%d-%s.jpg", safeCoverFileID(libraryID), width, height, sourceKey) + return filepath.Join(LibraryCoverCacheDir(p.cacheDirBase()), name), sourceKey +} + +func (p *ImageProxy) cacheDirBase() string { + if p != nil && p.cfg != nil { + return p.cfg.Cache.CacheDir + } + return "" +} + +// pruneLibraryCoverVariants 清掉同一个库、旧成员列表留下的封面文件(含各 +// 尺寸变体),当前成员列表对应的成品全部保留。 +func (p *ImageProxy) pruneLibraryCoverVariants(dir, libraryID, keepSourceKey string) { + entries, err := os.ReadDir(dir) + if err != nil { + return + } + prefix := safeCoverFileID(libraryID) + "-" + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".jpg") { + continue + } + if strings.Contains(name, "-"+keepSourceKey+".jpg") { + continue + } + _ = os.Remove(filepath.Join(dir, name)) // #nosec G304 -- name comes from ReadDir of our own cache dir. + } +} + +// safeCoverFileID 让文件名只包含安全字符(库 ID 来自数据库,但仍做一次 +// 白名单过滤,避免任何路径分隔符进入文件名)。 +func safeCoverFileID(libraryID string) string { + var b strings.Builder + for _, r := range libraryID { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '_': + b.WriteRune(r) + } + } + if b.Len() == 0 { + return "library" + } + return b.String() +} + +func normalizeCoverSources(sources []string) []string { + seen := make(map[string]struct{}, len(sources)) + out := make([]string, 0, len(sources)) + for _, raw := range sources { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + if _, ok := seen[raw]; ok { + continue + } + seen[raw] = struct{}{} + out = append(out, raw) + if len(out) >= libraryCoverMaxPanels { + break + } + } + return out +} + +// libraryCoverCanvas 把请求的尺寸收敛到 16:9 画布,并夹在合理区间内。 +func libraryCoverCanvas(width, height int) (int, int) { + if width <= 0 && height <= 0 { + return libraryCoverDefaultWidth, libraryCoverDefaultHeight + } + if width <= 0 { + width = int(float64(height)*libraryCoverAspect + 0.5) + } + if height <= 0 { + height = int(float64(width)/libraryCoverAspect + 0.5) + } + // 客户端给的是一个「不超过」的盒子:按 16:9 装进去。 + if float64(width)/float64(height) > libraryCoverAspect { + width = int(float64(height)*libraryCoverAspect + 0.5) + } else { + height = int(float64(width)/libraryCoverAspect + 0.5) + } + if width < libraryCoverMinWidth { + width = libraryCoverMinWidth + height = int(float64(width)/libraryCoverAspect + 0.5) + } + if width > libraryCoverMaxWidth { + width = libraryCoverMaxWidth + height = int(float64(width)/libraryCoverAspect + 0.5) + } + return width, height +} + +// composeLibraryCover 把若干张海报拼成库封面:竖切成若干等宽竖条。 +// +// - 1 张:铺满整块画布; +// - 2 张:左右各一半(默认形态,对应网页/客户端卡片的分屏观感); +// - 3/4 张:三等分 / 四等分竖条。 +// +// 每格按中心裁切铺满(crop-to-fill),这样不同比例的海报都能对齐分栏。 +func composeLibraryCover(images [][]byte, width, height int) ([]byte, error) { + if len(images) == 0 { + return nil, errors.New("no images to compose") + } + if width <= 0 || height <= 0 { + return nil, errors.New("invalid cover size") + } + decoded := make([]image.Image, 0, len(images)) + for _, data := range images { + img, ok := decodeCoverImage(data) + if ok { + decoded = append(decoded, img) + } + if len(decoded) >= libraryCoverMaxPanels { + break + } + } + if len(decoded) == 0 { + return nil, errors.New("no decodable cover images") + } + canvas := image.NewRGBA(image.Rect(0, 0, width, height)) + cells := make([]image.Rectangle, 0, len(decoded)) + if len(decoded) == 1 { + cells = append(cells, canvas.Bounds()) + } else { + for i := range decoded { + x0 := width * i / len(decoded) + x1 := width * (i + 1) / len(decoded) + cells = append(cells, image.Rect(x0, 0, x1, height)) + } + } + for i, cell := range cells { + drawCoverCell(canvas, cell, decoded[i]) + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, canvas, &jpeg.Options{Quality: libraryCoverQuality}); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// decodeCoverImage 解码一张海报,并对异常大的图做像素上限保护:一次拼图 +// 会同时解码 4 张,2 核 2G 的机器上不能任由超大原图把内存吃光。 +func decodeCoverImage(data []byte) (image.Image, bool) { + if len(data) == 0 { + return nil, false + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(data)) + if err != nil || cfg.Width <= 0 || cfg.Height <= 0 { + return nil, false + } + if cfg.Width*cfg.Height > imageResizeMaxSourcePixels { + return nil, false + } + img, _, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, false + } + return img, true +} + +// drawCoverCell 把一张图按中心裁切铺满目标格子。 +func drawCoverCell(dst *image.RGBA, cell image.Rectangle, src image.Image) { + if src == nil || cell.Dx() <= 0 || cell.Dy() <= 0 { + return + } + bounds := src.Bounds() + srcW, srcH := bounds.Dx(), bounds.Dy() + if srcW <= 0 || srcH <= 0 { + return + } + targetAspect := float64(cell.Dx()) / float64(cell.Dy()) + srcAspect := float64(srcW) / float64(srcH) + crop := bounds + if srcAspect > targetAspect { + cropW := int(float64(srcH)*targetAspect + 0.5) + if cropW < 1 { + cropW = 1 + } + x0 := bounds.Min.X + (srcW-cropW)/2 + crop = image.Rect(x0, bounds.Min.Y, x0+cropW, bounds.Max.Y) + } else if srcAspect < targetAspect { + cropH := int(float64(srcW)/targetAspect + 0.5) + if cropH < 1 { + cropH = 1 + } + y0 := bounds.Min.Y + (srcH-cropH)/2 + crop = image.Rect(bounds.Min.X, y0, bounds.Max.X, y0+cropH) + } + scale := float64(cell.Dx()) / float64(crop.Dx()) + var scaler draw.Scaler = draw.CatmullRom + if scale < imageResizeCheapScaleRatio { + scaler = draw.ApproxBiLinear + } + scaler.Scale(dst, cell, src, crop, draw.Src, nil) +} + +// writeFileAtomic 先写临时文件再 rename,避免并发的读取方读到半个文件。 +func writeFileAtomic(path string, data []byte) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".cover-*") + if err != nil { + return err + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + _ = os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + if err := os.Rename(tmp.Name(), path); err != nil { + _ = os.Remove(tmp.Name()) + return err + } + return nil +} + +// LibraryCoverSourcesForLibrary 是给 handler 的便捷封装:库存在且没有自定 +// 义封面时返回成员海报来源;远程挂载库由各自的上游负责封面,不在此生成。 +func LibraryCoverSourcesForLibrary(ctx context.Context, repos *repository.Container, libraryID string) ([]string, *model.Library) { + if repos == nil || repos.Library == nil { + return nil, nil + } + libraryID = strings.TrimSpace(libraryID) + if libraryID == "" { + return nil, nil + } + lib, err := repos.Library.FindByID(ctx, libraryID) + if err != nil || lib == nil { + return nil, nil + } + if strings.TrimSpace(lib.CoverURL) != "" { + return nil, lib + } + return LibraryCoverSources(ctx, repos, libraryID, libraryCoverSourceLimit), lib +} diff --git a/internal/service/library_cover_test.go b/internal/service/library_cover_test.go new file mode 100644 index 0000000..5baae7e --- /dev/null +++ b/internal/service/library_cover_test.go @@ -0,0 +1,335 @@ +package service + +import ( + "bytes" + "image" + "image/color" + "image/jpeg" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" +) + +func solidJPEG(t *testing.T, c color.Color, w, h int) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 95}); err != nil { + t.Fatalf("encode jpeg: %v", err) + } + return buf.Bytes() +} + +func decodeJPEG(t *testing.T, data []byte) image.Image { + t.Helper() + img, err := jpeg.Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("decode composed cover: %v", err) + } + return img +} + +// TestComposeLibraryCoverSplitsIntoSideBySideColumns 保证多张海报是竖切成 +// 左右分栏(左→右顺序),而不是 2×2 田字格。 +func TestComposeLibraryCoverSplitsIntoSideBySideColumns(t *testing.T) { + images := [][]byte{ + solidJPEG(t, color.RGBA{R: 220, A: 255}, 200, 300), + solidJPEG(t, color.RGBA{G: 220, A: 255}, 200, 300), + solidJPEG(t, color.RGBA{B: 220, A: 255}, 200, 300), + solidJPEG(t, color.RGBA{R: 220, G: 220, A: 255}, 200, 300), + } + out, err := composeLibraryCover(images, 480, 270) + if err != nil { + t.Fatalf("compose: %v", err) + } + img := decodeJPEG(t, out) + if img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 { + t.Fatalf("size = %v, want 480x270", img.Bounds()) + } + // 四张图 → 四条竖栏;同一栏从顶部到底部颜色一致(竖切,不是上下分割)。 + y := img.Bounds().Dy() / 2 + checks := []struct { + name string + x int + dominant func(color.RGBA) bool + }{ + {"column 1 red", 60, func(c color.RGBA) bool { return c.R > c.G+40 && c.R > c.B+40 }}, + {"column 2 green", 180, func(c color.RGBA) bool { return c.G > c.R+40 && c.G > c.B+40 }}, + {"column 3 blue", 300, func(c color.RGBA) bool { return c.B > c.R+40 && c.B > c.G+40 }}, + {"column 4 yellow", 420, func(c color.RGBA) bool { return c.R > c.B+40 && c.G > c.B+40 }}, + } + for _, check := range checks { + for _, yy := range []int{2, y, img.Bounds().Dy() - 3} { + r, g, b, _ := img.At(check.x, yy).RGBA() + got := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255} + if !check.dominant(got) { + t.Fatalf("%s at y=%d: pixel = %v", check.name, yy, got) + } + } + } +} + +// TestComposeLibraryCoverHandlesFewerPosters 覆盖 1/2/3 张成员的布局。 +func TestComposeLibraryCoverHandlesFewerPosters(t *testing.T) { + cases := []struct { + name string + count int + }{ + {"single", 1}, + {"pair", 2}, + {"triple", 3}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + images := make([][]byte, 0, tc.count) + for i := 0; i < tc.count; i++ { + images = append(images, solidJPEG(t, color.RGBA{R: 200, G: 120, A: 255}, 300, 450)) + } + out, err := composeLibraryCover(images, 320, 180) + if err != nil { + t.Fatalf("compose: %v", err) + } + img := decodeJPEG(t, out) + if img.Bounds().Dx() != 320 || img.Bounds().Dy() != 180 { + t.Fatalf("size = %v, want 320x180", img.Bounds()) + } + }) + } +} + +// TestComposeLibraryCoverSkipsUndecodableSources 保证一张坏图不会让整张 +// 封面失败:其余可解码的成员仍然会拼出来。 +func TestComposeLibraryCoverSkipsUndecodableSources(t *testing.T) { + images := [][]byte{ + []byte("not an image"), + solidJPEG(t, color.RGBA{G: 200, A: 255}, 200, 300), + } + out, err := composeLibraryCover(images, 240, 135) + if err != nil { + t.Fatalf("compose: %v", err) + } + if len(out) == 0 { + t.Fatal("expected composed cover bytes") + } + if _, err := composeLibraryCover([][]byte{[]byte("nope")}, 240, 135); err == nil { + t.Fatal("expected error when no source is decodable") + } +} + +func TestLibraryCoverCanvasKeepsSixteenByNine(t *testing.T) { + cases := []struct { + inW, inH int + wantW int + wantH int + checkRatio bool + }{ + {0, 0, 480, 270, true}, + {640, 480, 640, 360, true}, // 4:3 盒子 → 装进 16:9 + {2000, 0, 1920, 1080, true}, // 超上限 → 夹紧 + {40, 0, 96, 54, true}, // 低于下限 → 抬到最小宽度 + {0, 540, 960, 540, false}, // 只给高度 → 按 16:9 推宽度 + } + for _, tc := range cases { + w, h := libraryCoverCanvas(tc.inW, tc.inH) + if w != tc.wantW || h != tc.wantH { + t.Fatalf("libraryCoverCanvas(%d,%d) = %dx%d, want %dx%d", tc.inW, tc.inH, w, h, tc.wantW, tc.wantH) + } + if tc.checkRatio && w != 0 && h != 0 { + ratio := float64(w) / float64(h) + if ratio < 1.7 || ratio > 1.85 { + t.Fatalf("ratio for %dx%d = %.3f, want ~1.78", tc.inW, tc.inH, ratio) + } + } + } +} + +// TestLibraryCoverSourcesDedupesSeries 保证封面不会两张都是同一部剧的不同集, +// 并且优先取海报、没有海报时退回剧照、完全没有图的成员被跳过。 +func TestLibraryCoverSourcesDedupesSeries(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "剧集", Path: "/media/tv", Type: "tv", Enabled: true} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + rows := []model.Media{ + {Base: model.Base{ID: "a1"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E01.mkv", SeriesID: "series-a", PosterURL: "https://img/a1.jpg"}, + {Base: model.Base{ID: "a2"}, LibraryID: lib.ID, Title: "A", Path: "/media/tv/A/S01E02.mkv", SeriesID: "series-a", PosterURL: "https://img/a2.jpg"}, + {Base: model.Base{ID: "b1"}, LibraryID: lib.ID, Title: "B", Path: "/media/tv/B/S01E01.mkv", SeriesID: "series-b", PosterURL: "https://img/b1.jpg"}, + {Base: model.Base{ID: "c1"}, LibraryID: lib.ID, Title: "C", Path: "/media/tv/C/S01E01.mkv", SeriesID: "series-c", BackdropURL: "https://img/c1-backdrop.jpg"}, + {Base: model.Base{ID: "d1"}, LibraryID: lib.ID, Title: "D", Path: "/media/tv/D/S01E01.mkv", SeriesID: "series-d"}, + } + for i := range rows { + if err := svc.repo.DB.Create(&rows[i]).Error; err != nil { + t.Fatalf("create media: %v", err) + } + } + // 竖切三栏:一部剧只出一张,无图成员被跳过 → 正好 3 张。 + sources := LibraryCoverSources(t.Context(), svc.repo, lib.ID, libraryCoverSourceLimit) + if len(sources) != 3 { + t.Fatalf("sources = %v, want 3 (一部剧一张、跳过无图成员)", sources) + } + seen := map[string]bool{} + for _, src := range sources { + if seen[src] { + t.Fatalf("duplicate source in %v", sources) + } + seen[src] = true + } + // 同一部剧只会出现一次:具体保留哪一集由查询顺序决定,但绝不能两张。 + if seen["https://img/a1.jpg"] && seen["https://img/a2.jpg"] { + t.Fatalf("same series contributed two posters: %v", sources) + } + if !seen["https://img/a1.jpg"] && !seen["https://img/a2.jpg"] { + t.Fatalf("series A is missing from sources: %v", sources) + } + if !seen["https://img/c1-backdrop.jpg"] { + t.Fatalf("member without poster should fall back to backdrop: %v", sources) + } +} + +// TestLibraryCoverImageCachesComposedBytes 验证生成结果会落盘复用:同一份 +// 内容第二次请求不再回源。 +func TestLibraryCoverImageCachesComposedBytes(t *testing.T) { + var hits int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&hits, 1) + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(solidJPEG(t, color.RGBA{R: 180, G: 140, B: 90, A: 255}, 240, 360)) + })) + defer upstream.Close() + + parsed, err := url.Parse(upstream.URL) + if err != nil { + t.Fatalf("parse upstream: %v", err) + } + cacheCfg := &config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}} + proxy := NewImageProxy(cacheCfg, zap.NewNop()) + proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} }) + + sources := []string{upstream.URL + "/1.jpg", upstream.URL + "/2.jpg", upstream.URL + "/3.jpg", upstream.URL + "/4.jpg"} + first, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270) + if err != nil { + t.Fatalf("first cover: %v", err) + } + if img := decodeJPEG(t, first); img.Bounds().Dx() != 480 || img.Bounds().Dy() != 270 { + t.Fatalf("size = %v, want 480x270", img.Bounds()) + } + hitsAfterFirst := atomic.LoadInt32(&hits) + if hitsAfterFirst != 4 { + t.Fatalf("upstream hits = %d, want one per source", hitsAfterFirst) + } + + second, err := proxy.LibraryCoverImage(t.Context(), "lib-1", sources, 480, 270) + if err != nil { + t.Fatalf("second cover: %v", err) + } + if !bytes.Equal(first, second) { + t.Fatal("cached cover bytes differ from the first build") + } + if got := atomic.LoadInt32(&hits); got != hitsAfterFirst { + t.Fatalf("cache miss: upstream hits %d -> %d", hitsAfterFirst, got) + } + + // 内容变了要重新生成(成员海报换了新文件)。已缓存的三张海报不会重下, + // 只有新增的那张会回源。 + changed := append([]string{}, sources[:3]...) + changed = append(changed, upstream.URL+"/5.jpg") + if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 480, 270); err != nil { + t.Fatalf("cover after content change: %v", err) + } + if got := atomic.LoadInt32(&hits); got != hitsAfterFirst+1 { + t.Fatalf("content change should rebuild with one new fetch, hits = %d", got) + } + + // 同一批成员的另一个尺寸档位要能共存:Web/App 与 Emby 请求的尺寸不同, + // 不能互相把对方的成品当「旧内容」删掉。 + if _, err := proxy.LibraryCoverImage(t.Context(), "lib-1", changed, 400, 225); err != nil { + t.Fatalf("other size cover: %v", err) + } + entries, err := os.ReadDir(LibraryCoverCacheDir(cacheCfg.Cache.CacheDir)) + if err != nil { + t.Fatalf("read cover cache dir: %v", err) + } + files := make([]string, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, entry.Name()) + } + } + if len(files) != 2 { + t.Fatalf("expected 480x270 and 400x225 variants to coexist, got %v", files) + } +} + +// TestLibraryCoverSourceBytesReadsLocalSidecarPoster 覆盖刮削把海报存成 +// /media/... 本地绝对路径的情况:这类来源没有 http(s) host,不能走 Fetch, +// 必须按库根目录白名单直接读盘,否则整张拼图会因为「没有可解码的海报」而 +// 生成失败(生产环境所有本地库都是这种来源)。 +func TestLibraryCoverSourceBytesReadsLocalSidecarPoster(t *testing.T) { + libDir := t.TempDir() + posterPath := filepath.Join(libDir, "poster.jpg") + if err := os.WriteFile(posterPath, solidJPEG(t, color.RGBA{R: 180, G: 90, A: 255}, 200, 300), 0o644); err != nil { + t.Fatalf("write poster: %v", err) + } + proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) + + // 未登记为库根目录时必须拒绝读取。 + if _, err := proxy.coverSourceBytes(t.Context(), posterPath); err == nil { + t.Fatal("expected local path outside the library roots to be rejected") + } + + proxy.SetLibraryRootsProvider(func() []string { return []string{libDir} }) + data, err := proxy.coverSourceBytes(t.Context(), posterPath) + if err != nil { + t.Fatalf("read local poster: %v", err) + } + if len(data) == 0 { + t.Fatal("expected poster bytes") + } + if _, ok := decodeCoverImage(data); !ok { + t.Fatal("local poster should decode") + } +} + +// TestLibraryCoverSourcesForLibrarySkipsCustomCover 保证管理员填了自定义 +// 封面时不会再生成拼图(沿用自定义图)。 +func TestLibraryCoverSourcesForLibrarySkipsCustomCover(t *testing.T) { + svc := newTestEmbyService(t) + lib := model.Library{Name: "电影", Path: "/media/movies", Type: "movie", CoverURL: "https://example.com/custom.jpg"} + if err := svc.repo.Library.Create(t.Context(), &lib); err != nil { + t.Fatalf("create library: %v", err) + } + if err := svc.repo.DB.Create(&model.Media{ + Base: model.Base{ID: "m1"}, + LibraryID: lib.ID, + Title: "M", + Path: "/media/movies/m1.mkv", + PosterURL: "https://img/m1.jpg", + }).Error; err != nil { + t.Fatalf("create media: %v", err) + } + sources, found := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, lib.ID) + if found == nil || found.CoverURL != lib.CoverURL { + t.Fatalf("library = %#v, want the existing library", found) + } + if len(sources) != 0 { + t.Fatalf("expected no generated sources with a custom cover, got %v", sources) + } + if _, blank := LibraryCoverSourcesForLibrary(t.Context(), svc.repo, "missing"); blank != nil { + t.Fatalf("unknown library should return nil, got %#v", blank) + } +} diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 59b0efb..9b2fbcf 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -126,13 +126,6 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { } cfg.Cache.ImagesOriginalsMaxSizeMB = n } - case "cache.images_originals_ttl_hours": - if n, err := strconv.Atoi(value); err == nil { - if n < 0 { - n = 0 - } - cfg.Cache.ImagesOriginalsTTLHours = n - } case "cache.memory_max_size_mb": n := config.DefaultCacheMemoryMaxSizeMB if value != "" { diff --git a/internal/service/scheduler.go b/internal/service/scheduler.go index 951b972..5248162 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -80,10 +80,10 @@ func (s *SchedulerService) SetExpiryWatcher(watcher *TelegramExpiryWatcher) { } // ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。 +// 只有容量配额,没有保留时长:淘汰只看占用,不按文件年龄删除。 type ImageCachePolicy struct { TotalBytes int64 OriginalsBytes int64 - OriginalsAge time.Duration } func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) { diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 3f6c110..d0fcee9 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -299,18 +299,18 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error { return walkAndPrune(s.cacheDir+"/hls", cutoff) } -// jobCleanImageCache prunes the image proxy cache: 原图按保留时长与独立配额 -// 优先淘汰,总量超限时再淘汰派生成品。 +// jobCleanImageCache prunes the image proxy cache: 原图按独立配额优先淘汰, +// 总量超限时再淘汰派生成品。全程只看占用,不按文件年龄删除。 func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error { if s.cacheDir == "" { return nil } policy := s.imageCachePolicy() - if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 && policy.OriginalsAge <= 0 { + if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 { return nil } imagesDir := filepath.Join(s.cacheDir, "images") - pools := ImageCachePools(imagesDir, policy.OriginalsBytes, policy.OriginalsAge) + pools := ImageCachePools(imagesDir, policy.OriginalsBytes) res, err := PruneImageCachePools(pools, policy.TotalBytes) if err != nil { if s.log != nil { diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 048f010..9ca074f 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -4,7 +4,6 @@ import ( "context" "errors" "strings" - "time" "go.uber.org/zap" @@ -222,9 +221,6 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() { if b.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 { policy.OriginalsBytes = int64(b.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024 } - if b.cfg.Cache.ImagesOriginalsTTLHours > 0 { - policy.OriginalsAge = time.Duration(b.cfg.Cache.ImagesOriginalsTTLHours) * time.Hour - } return policy }) // 阅读正文缓存清理:由阅读模块自己按 TTL + 容量 LRU 淘汰(见 reader_content_cache.go)。 diff --git a/web/src/pages/HomePageSections.tsx b/web/src/pages/HomePageSections.tsx index 9a5f0fa..c4ba824 100644 --- a/web/src/pages/HomePageSections.tsx +++ b/web/src/pages/HomePageSections.tsx @@ -464,7 +464,9 @@ export function HomeLibrariesSection({ pinned={pinnedIds.includes(lib.id)} onTogglePin={onTogglePin ? () => onTogglePin(lib.id) : undefined} onVisible={() => { - if (!lib.cover_url) { + // 服务端会生成库封面时不必再为首屏卡片拉成员预览; + // 远程挂载库的封面仍由卡片拼图兜底。 + if (!lib.cover_url && !lib.cover_image_url) { queuePreview(lib.id) } }} diff --git a/web/src/pages/librariesPageModel.ts b/web/src/pages/librariesPageModel.ts index 259f730..6c729fc 100644 --- a/web/src/pages/librariesPageModel.ts +++ b/web/src/pages/librariesPageModel.ts @@ -43,5 +43,12 @@ export function getLibraryArtworks( if (library.cover_url) { return [{ src: library.cover_url, version: library.updated_at }] } + // 服务端生成的库封面(成员海报竖切拼图):所有客户端共用一张图, + // 不再由前端拿两张卡片自己拼格子。 + // version 里带一个版次后缀:接口地址是固定的,靠它让浏览器在拼图规则 + // 调整(如田字格改成竖切)后丢掉旧缓存重新拉图。 + if (library.cover_image_url) { + return [{ src: library.cover_image_url, version: `${library.updated_at}-c3` }] + } return libraryArtworkItems(cards) } diff --git a/web/src/pages/settingsGroupGeneral.ts b/web/src/pages/settingsGroupGeneral.ts index 055b647..45e2c07 100644 --- a/web/src/pages/settingsGroupGeneral.ts +++ b/web/src/pages/settingsGroupGeneral.ts @@ -130,14 +130,6 @@ export const generalSettingsGroup: SettingGroup = { defaultValue: '256', placeholder: '256', }, - { - key: 'cache.images_originals_ttl_hours', - label: '原图保留时长 (小时)', - type: 'number', - hint: '原图缓存的最长保留时间,超过即删除(默认 168 小时 = 7 天)。缩略图都已生成时原图就不再需要,缩短它可以让原图缓存保持在一个滚动窗口内。设为 0 表示不按时间淘汰', - defaultValue: '168', - placeholder: '168', - }, { key: 'https.enabled', label: '启用 HTTPS', diff --git a/web/src/types/library.ts b/web/src/types/library.ts index e696839..4cf03d5 100644 --- a/web/src/types/library.ts +++ b/web/src/types/library.ts @@ -15,6 +15,12 @@ export interface Library { path: string type: string cover_url?: string + /** + * 服务端生成的封面地址(成员海报拼图)。管理员自定义 cover_url 时等于 + * 该地址,否则指向 `/api/libraries/:id/cover`。Web/App 统一读这个字段, + * 不用再自己用卡片拼格子。 + */ + cover_image_url?: string enabled: boolean sort_order?: number carousel_enabled?: boolean