diff --git a/docs/reading-module-design.md b/docs/reading-module-design.md index 7706d7c..d77138a 100644 --- a/docs/reading-module-design.md +++ b/docs/reading-module-design.md @@ -52,9 +52,9 @@ internal/model/book.go ← GORM 模型,注册进 model.AllModels | `cloud.Provider.Resolve(ctx, fileRef) (*DirectLink, error)`(`internal/service/cloud/cloud.go`) | 网盘书源的直链解析入口,`DirectLink.Proxy` 决定 302 还是反代 | | `model.StorageConfig`(`internal/model/storage_assistant.go`) | 直接复用为网盘书源的账号凭据载体,**不新建凭据表** | | `service.ImageProxy`(`internal/service/image_proxy*.go`) | 漫画页与封面的磁盘缓存 + 远程拉取 + 缩放,复用其缓存目录与命名思路 | -| `service.PruneImageCache`(`internal/service/cache_cleanup.go`) | 现成的「按总大小做 LRU 淘汰」助手,书籍缓存淘汰直接复用它 | +| `service.PruneImageCache` / `PruneImageCachePools`(`internal/service/cache_cleanup.go`) | 现成的「按池做 LRU 淘汰 + 按保留时长淘汰」助手,书籍缓存淘汰直接复用它 | | `service/scheduler_local_jobs.go` | 本地定时任务的挂载点,书籍缓存清理与每日统计汇总都注册在这里 | -| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `MaxDiskUsageMB` / `TTLHours` / `AutoCleanup` / `CleanupIntervalMin`,书籍缓存容量配置直接挂进去 | +| `config.CacheConfig`(`internal/config/types.go`) | 已有 `CacheDir` / `ImagesMaxSizeMB` / `ImagesOriginalsMaxSizeMB` / `ImagesOriginalsTTLHours` / `MemoryMaxSizeMB`,书籍缓存容量配置直接挂进去 | | `service.FileManager`(`internal/service/filemanager.go`) | 本地书源目录浏览,前端复用 `LocalDirBrowserDialog.tsx` | | `service.Scheduler` | 书库定时扫描(默认关闭,管理员可开) | | `model.UserPermission` | 新增阅读权限位,见第 7 节 | diff --git a/internal/config/defaults.go b/internal/config/defaults.go index e221e8f..cf49a3d 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -45,8 +45,9 @@ 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.cleanup_interval_min", 60) v.SetDefault("cache.redis_url", "") v.SetDefault("cache.redis_prefix", "mebox") v.SetDefault("cache.media_ttl_seconds", 90) diff --git a/internal/config/normalize.go b/internal/config/normalize.go index 07aa6d6..812db71 100644 --- a/internal/config/normalize.go +++ b/internal/config/normalize.go @@ -47,6 +47,12 @@ func (c *Config) normalize() error { if c.Cache.ImagesMaxSizeMB < 0 { c.Cache.ImagesMaxSizeMB = 0 } + 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 550968d..70abf3d 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -117,15 +117,17 @@ type LoggingConfig struct { type CacheConfig struct { CacheDir string `mapstructure:"cache_dir"` ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"` + // 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"` - MaxDiskUsageMB int `mapstructure:"max_disk_usage_mb"` - TTLHours int `mapstructure:"ttl_hours"` - AutoCleanup bool `mapstructure:"auto_cleanup"` - CleanupIntervalMin int `mapstructure:"cleanup_interval_min"` - RedisURL string `mapstructure:"redis_url"` - RedisPrefix string `mapstructure:"redis_prefix"` - MediaTTLSeconds int `mapstructure:"media_ttl_seconds"` + MemoryMaxSizeMB int `mapstructure:"memory_max_size_mb"` + RedisURL string `mapstructure:"redis_url"` + RedisPrefix string `mapstructure:"redis_prefix"` + MediaTTLSeconds int `mapstructure:"media_ttl_seconds"` // EmbyLatestTTLSeconds 是 Emby「最新添加」(Items/Latest) 的缓存时长。 // 客户端刷新首页时会并发请求全部媒体库的 Latest(生产环境观察到 73 个 // 并发),缓存过短会让这批请求同时穿透并各自重建 payload,在低配主机 diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index f62a32f..932745b 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -65,8 +65,10 @@ 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" && svc.Scheduler != nil { - _ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup") + if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" || req.Key == "cache.images_originals_ttl_hours" { + if svc.Scheduler != nil { + _ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup") + } } if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil { svc.Cache.SetMaxSizeMB(svc.Cfg.Cache.MemoryMaxSizeMB) diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go index bf83ec9..c2c37d2 100644 --- a/internal/service/cache_cleanup.go +++ b/internal/service/cache_cleanup.go @@ -4,7 +4,7 @@ package service import ( - "errors" + "encoding/hex" "os" "path/filepath" "sort" @@ -55,18 +55,287 @@ type PruneImageCacheResult struct { RemainingBytes int64 } +// imageCacheFileEntry 是池内一个可被淘汰的缓存文件。 type imageCacheFileEntry struct { path string + pool int size int64 modTime time.Time } -// PruneImageCache scans imagesDir for cached image files. If the total disk usage -// exceeds maxSizeBytes, it removes files starting from the oldest (by ModTime) -// until disk usage falls to or below targetSizeBytes (80% of maxSizeBytes). +// ImageCacheScope 是池内的一处文件范围。Recursive=false 时只处理该目录下的 +// 直属文件(历史版本的图片缓存是平铺在 images/ 下的,升级后仍要能清理掉)。 +type ImageCacheScope struct { + Root string + Recursive bool + // DropUnknownResizeVariants 只用于派生缩略图目录:直接删除文件名不符合当前 + // 命名(<源图键>.<宽>x<高>q<质量>.img)的历史缩放缓存。它们在新的查找路径下 + // 永远不会被命中,留着只会占用总量配额、把有用缓存挤出去。 + DropUnknownResizeVariants bool +} + +// ImageCachePool 描述一个图片缓存池:范围 + 自身配额 + 保留时长。 // -// In-flight temporary files (*.tmp) are skipped to avoid corrupting concurrent writes. -// Empty subdirectories left behind are best-effort removed. +// 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料, +// 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。 +// 因此原图配小配额、短保留,总量超限时也优先淘汰原图。 +type ImageCachePool struct { + Name string + Scopes []ImageCacheScope + MaxBytes int64 // 0 = 不单独限制 + MaxAge time.Duration // 0 = 不按时间淘汰 +} + +// imageFailMarkerMaxAge 是失败标记文件的保留时长:标记只用于观测/重试, +// 过期即视为噪音清掉(不会因为标记残留而阻止重新抓取)。 +const imageFailMarkerMaxAge = 24 * time.Hour + +// tempImageCacheFile 报告文件名是否是下载/缩放过程中的临时文件。它们正在被 +// 并发写入,清理时必须跳过。 +func tempImageCacheFile(name string) bool { + if strings.HasSuffix(name, ".tmp") { + return true + } + return strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") +} + +// sha256HexLength 是十六进制 sha256 摘要的长度。 +const sha256HexLength = 64 + +// isCurrentResizeVariantName 报告文件名是否符合当前的缩放缓存命名 +// (<源图键>.<宽>x<高>q<质量>.img)。 +func isCurrentResizeVariantName(name string) bool { + if !strings.HasSuffix(name, ".img") { + return false + } + spec := strings.TrimSuffix(name, ".img") + key, _, found := strings.Cut(spec, ".") + if !found || len(key) != sha256HexLength { + return false + } + if _, err := hex.DecodeString(key); err != nil { + return false + } + _, _, ok := parseResizeVariantSuffix(name, key) + return ok +} + +// collectImageCachePoolFiles 收集池内可淘汰的文件(跳过并发写入中的临时文件)。 +// 失败标记单独返回,它们不计入容量,只按年龄清理。 +func collectImageCachePoolFiles(pool ImageCachePool, poolIndex int, entries *[]imageCacheFileEntry, failMarkers *[]imageCacheFileEntry) { + visit := func(path string, info os.FileInfo, scope ImageCacheScope) { + if info.IsDir() { + return + } + name := info.Name() + if tempImageCacheFile(name) { + return + } + if scope.DropUnknownResizeVariants && strings.HasSuffix(name, ".img") && !isCurrentResizeVariantName(name) { + _ = os.Remove(path) // 旧版命名,永远不会命中,直接清掉 + return + } + entry := imageCacheFileEntry{path: path, pool: poolIndex, size: info.Size(), modTime: info.ModTime()} + if strings.HasSuffix(name, ".fail") { + *failMarkers = append(*failMarkers, entry) + return + } + *entries = append(*entries, entry) + } + for _, scope := range pool.Scopes { + if strings.TrimSpace(scope.Root) == "" { + continue + } + if !scope.Recursive { + dir, err := os.Open(scope.Root) // #nosec G304 -- cache root from config. + if err != nil { + continue + } + names, err := dir.Readdir(-1) + _ = dir.Close() + if err != nil { + continue + } + for _, info := range names { + visit(filepath.Join(scope.Root, info.Name()), info, scope) + } + continue + } + _ = filepath.Walk(scope.Root, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil // best-effort: one unreadable dir must not abort cleanup + } + visit(path, info, scope) + return nil + }) + } +} + +// removeImageCacheEntry 删除一个缓存文件,并顺带清掉它的失败标记。 +func removeImageCacheEntry(entry imageCacheFileEntry, result *PruneImageCacheResult) { + if err := os.Remove(entry.path); err != nil { + return + } + result.DeletedFiles++ + result.FreedBytes += entry.size + result.RemainingBytes -= entry.size + _ = os.Remove(entry.path + ".fail") +} + +// PruneImageCachePools 按池清理图片缓存: +// 1. 删除超过池 MaxAge 的文件(原图按保留时长淘汰); +// 2. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发); +// 3. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件, +// 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。 +// +// 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。 +func PruneImageCachePools(pools []ImageCachePool, totalBytes int64) (PruneImageCacheResult, error) { + var result PruneImageCacheResult + if len(pools) == 0 { + return result, nil + } + + var entries []imageCacheFileEntry + var failMarkers []imageCacheFileEntry + for i, pool := range pools { + collectImageCachePoolFiles(pool, i, &entries, &failMarkers) + } + + result.TotalFilesBefore = len(entries) + for _, entry := range entries { + result.TotalBytesBefore += entry.size + } + result.RemainingBytes = result.TotalBytesBefore + + // 过期的失败标记直接清掉:它们不计容量,只用于观测与重试。 + cutoffMarkers := time.Now().Add(-imageFailMarkerMaxAge) + for _, marker := range failMarkers { + if marker.modTime.Before(cutoffMarkers) { + _ = os.Remove(marker.path) + } + } + + now := time.Now() + deleted := make(map[string]bool, len(entries)) + deleteEntry := func(entry imageCacheFileEntry) { + if deleted[entry.path] { + return + } + deleted[entry.path] = true + 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. 池自身配额。 + for i := range pools { + maxBytes := pools[i].MaxBytes + if maxBytes <= 0 { + continue + } + var poolBytes int64 + for _, entry := range entries { + if entry.pool == i && !deleted[entry.path] { + poolBytes += entry.size + } + } + if poolBytes <= maxBytes { + continue + } + target := maxBytes * 80 / 100 + for _, entry := range oldestFirst(entries, i, deleted) { + if poolBytes <= target { + break + } + deleteEntry(entry) + poolBytes -= entry.size + } + } + + // 3. 总量硬上限:跨池按淘汰优先级(池顺序)再按 mtime 淘汰。 + if totalBytes > 0 && result.RemainingBytes > totalBytes { + target := totalBytes * 80 / 100 + for _, entry := range evictionOrder(entries, deleted) { + if result.RemainingBytes <= target { + break + } + deleteEntry(entry) + } + } + + removeEmptyImageCacheDirs(pools) + return result, nil +} + +// oldestFirst 返回指定池内未被删除的文件,按修改时间从旧到新。 +func oldestFirst(entries []imageCacheFileEntry, pool int, deleted map[string]bool) []imageCacheFileEntry { + out := make([]imageCacheFileEntry, 0, len(entries)) + for _, entry := range entries { + if entry.pool == pool && !deleted[entry.path] { + out = append(out, entry) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].modTime.Before(out[j].modTime) }) + return out +} + +// evictionOrder 返回总量超限时的淘汰顺序:先按池优先级(原图池在前), +// 池内再按修改时间从旧到新。 +func evictionOrder(entries []imageCacheFileEntry, deleted map[string]bool) []imageCacheFileEntry { + out := make([]imageCacheFileEntry, 0, len(entries)) + for _, entry := range entries { + if !deleted[entry.path] { + out = append(out, entry) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].pool != out[j].pool { + return out[i].pool < out[j].pool + } + return out[i].modTime.Before(out[j].modTime) + }) + return out +} + +// removeEmptyImageCacheDirs 清理淘汰后留下的空子目录(不含池根目录本身)。 +func removeEmptyImageCacheDirs(pools []ImageCachePool) { + seen := map[string]bool{} + for _, pool := range pools { + for _, scope := range pool.Scopes { + if !scope.Recursive || seen[scope.Root] { + continue + } + seen[scope.Root] = true + dirs := []string{} + _ = filepath.Walk(scope.Root, func(path string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if info.IsDir() && path != scope.Root { + dirs = append(dirs, path) + } + return nil + }) + for i := len(dirs) - 1; i >= 0; i-- { + _ = os.Remove(dirs[i]) + } + } + } +} + +// PruneImageCache 是单池版本的兼容入口:把整个图片目录当作一个池,超限时按 +// mtime 淘汰到 80%。保留它是为了「立刻清理图片缓存」这类只关心总量的调用方。 func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResult, error) { var result PruneImageCacheResult if imagesDir == "" || maxSizeBytes <= 0 { @@ -75,66 +344,35 @@ func PruneImageCache(imagesDir string, maxSizeBytes int64) (PruneImageCacheResul if _, err := os.Stat(imagesDir); err != nil { return result, nil } - - var ( - dirs []string - entries []imageCacheFileEntry - ) - - _ = filepath.Walk(imagesDir, func(path string, info os.FileInfo, err error) error { - if err != nil { - return nil - } - if info.IsDir() { - if path != imagesDir { - dirs = append(dirs, path) - } - return nil - } - // Skip temporary files created during image download. - name := info.Name() - if strings.HasSuffix(name, ".tmp") || strings.HasPrefix(name, "img-") && strings.Contains(name, ".tmp") { - return nil - } - size := info.Size() - result.TotalFilesBefore++ - result.TotalBytesBefore += size - entries = append(entries, imageCacheFileEntry{ - path: path, - size: size, - modTime: info.ModTime(), - }) - return nil - }) - - result.RemainingBytes = result.TotalBytesBefore - if result.TotalBytesBefore <= maxSizeBytes { - return result, nil + pool := ImageCachePool{ + Name: "images", + Scopes: []ImageCacheScope{{Root: imagesDir, Recursive: true}}, + MaxBytes: maxSizeBytes, } - - // High/Low watermark: prune down to 80% of max size to leave headroom - // and prevent disk thrashing on consecutive writes. - targetSizeBytes := maxSizeBytes * 80 / 100 - - sort.Slice(entries, func(i, j int) bool { - return entries[i].modTime.Before(entries[j].modTime) - }) - - for _, entry := range entries { - if result.RemainingBytes <= targetSizeBytes { - break - } - if err := os.Remove(entry.path); err == nil || errors.Is(err, os.ErrNotExist) { - result.DeletedFiles++ - result.FreedBytes += entry.size - result.RemainingBytes -= entry.size - } - } - - // Clean up emptied subdirectories from deepest to shallowest. - for i := len(dirs) - 1; i >= 0; i-- { - _ = os.Remove(dirs[i]) - } - - return result, nil + return PruneImageCachePools([]ImageCachePool{pool}, maxSizeBytes) +} + +// ImageCachePools 按配置组装图片缓存的两个清理池。 +// +// 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被 +// 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。 +// 池顺序即总量超限时的淘汰优先级。 +func ImageCachePools(imagesDir string, originalsMaxBytes int64, originalsMaxAge time.Duration) []ImageCachePool { + originals := ImageCachePool{ + Name: "originals", + MaxBytes: originalsMaxBytes, + MaxAge: originalsMaxAge, + Scopes: []ImageCacheScope{ + {Root: imagesDir, Recursive: false}, // 旧版平铺布局 + {Root: filepath.Join(imagesDir, imageOriginalCacheSubdir), Recursive: true}, + }, + } + derived := ImageCachePool{ + Name: "derived", + Scopes: []ImageCacheScope{ + {Root: filepath.Join(imagesDir, imageResizeCacheSubdir), Recursive: true, DropUnknownResizeVariants: true}, + {Root: filepath.Join(imagesDir, imageRenditionCacheSubdir), Recursive: true}, + }, + } + return []ImageCachePool{originals, derived} } diff --git a/internal/service/cache_cleanup_pools_test.go b/internal/service/cache_cleanup_pools_test.go new file mode 100644 index 0000000..66f5b07 --- /dev/null +++ b/internal/service/cache_cleanup_pools_test.go @@ -0,0 +1,223 @@ +package service + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +// writePoolFile ????????????????????????? +func writePoolFile(t *testing.T, path string, size int, modTime time.Time) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + t.Fatalf("mkdir %s: %v", filepath.Dir(path), err) + } + if err := os.WriteFile(path, make([]byte, size), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } + if !modTime.IsZero() { + if err := os.Chtimes(path, modTime, modTime); err != nil { + t.Fatalf("chtimes %s: %v", path, err) + } + } +} + +func mustNotExist(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("expected %s to be deleted, got err=%v", path, err) + } +} + +func mustExist(t *testing.T, path string) { + t.Helper() + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to survive, got err=%v", path, err) + } +} + +// currentVariantPath ????????????????..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) { + 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)) + + pools := ImageCachePools(imagesDir, 0, 7*24*time.Hour) + 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) + } + mustNotExist(t, stale) + mustExist(t, fresh) + mustExist(t, thumb) +} + +// TestPruneImageCachePoolsEvictsOriginalsBeforeDerived ???????????? +// ???????????????????? +func TestPruneImageCachePoolsEvictsOriginalsBeforeDerived(t *testing.T) { + imagesDir := t.TempDir() + now := time.Now() + + 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?? + res, err := PruneImageCachePools(pools, 2*1024*1024+400*1024) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + if res.DeletedFiles != 2 { + t.Fatalf("deleted files = %d, want 2 (original + oldest derived)", res.DeletedFiles) + } + mustNotExist(t, original) + mustNotExist(t, rendition) + mustExist(t, thumb) + if res.RemainingBytes != 1024*1024 { + t.Fatalf("remaining bytes = %d, want %d", res.RemainingBytes, 1024*1024) + } +} + +// TestPruneImageCachePoolsOriginalQuotaKeepsDerived ???????????? +// ?????????????? +func TestPruneImageCachePoolsOriginalQuotaKeepsDerived(t *testing.T) { + imagesDir := t.TempDir() + now := time.Now() + + oldOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "old") + newOriginal := filepath.Join(imagesDir, imageOriginalCacheSubdir, "new") + thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80) + writePoolFile(t, oldOriginal, 800, now.Add(-3*time.Hour)) + 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) + res, err := PruneImageCachePools(pools, 0) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + if res.DeletedFiles != 1 { + t.Fatalf("deleted files = %d, want 1", res.DeletedFiles) + } + mustNotExist(t, oldOriginal) + mustExist(t, newOriginal) + mustExist(t, thumb) +} + +// TestPruneImageCachePoolsCleansFailMarkers ???????????????? +// ???????????????? +func TestPruneImageCachePoolsCleansFailMarkers(t *testing.T) { + imagesDir := t.TempDir() + now := time.Now() + + entry := filepath.Join(imagesDir, imageOriginalCacheSubdir, "entry") + entryMarker := entry + ".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, orphanMarker, 35, now.Add(-48*time.Hour)) + writePoolFile(t, freshMarker, 35, now.Add(-1*time.Minute)) + + // ???? 500????900 ????????????????? + pools := ImageCachePools(imagesDir, 500, 0) + res, err := PruneImageCachePools(pools, 0) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + if res.DeletedFiles != 1 { + t.Fatalf("deleted files = %d, want 1", res.DeletedFiles) + } + mustNotExist(t, entry) + mustNotExist(t, entryMarker) + mustNotExist(t, orphanMarker) + mustExist(t, freshMarker) +} + +// 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) + if _, err := PruneImageCachePools(pools, 1); err != nil { + t.Fatalf("prune failed: %v", err) + } + mustExist(t, tmp) +} + +// TestPruneImageCachePoolsDropsLegacyResizeVariants ????????.img???? +// ????????????????????????????????????? +func TestPruneImageCachePoolsDropsLegacyResizeVariants(t *testing.T) { + imagesDir := t.TempDir() + resized := filepath.Join(imagesDir, imageResizeCacheSubdir) + legacy := filepath.Join(resized, "4f2a1b"+strings.Repeat("0", 58)+".img") + current := filepath.Join(resized, strings.Repeat("a", 64)+".480x600q80.img") + other := filepath.Join(resized, "not-a-hash.480x600q80.img") + writePoolFile(t, legacy, 400, time.Now()) + writePoolFile(t, current, 400, time.Now()) + writePoolFile(t, other, 400, time.Now()) + + pools := ImageCachePools(imagesDir, 0, 0) + res, err := PruneImageCachePools(pools, 0) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + mustNotExist(t, legacy) + mustNotExist(t, other) + mustExist(t, current) + if res.TotalFilesBefore != 1 { + t.Fatalf("counted files = %d, want only the current-format variant", res.TotalFilesBefore) + } +} + +// TestPruneImageCachePoolsCountsLegacyFlatLayout ??????images/ ???? +// ????????????????? +func TestPruneImageCachePoolsCountsLegacyFlatLayout(t *testing.T) { + imagesDir := t.TempDir() + legacy := filepath.Join(imagesDir, "legacy-flat-cache") + thumb := currentVariantPath(filepath.Join(imagesDir, imageResizeCacheSubdir), "c", 480, 600, 80) + 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) + res, err := PruneImageCachePools(pools, 0) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + if res.DeletedFiles != 1 { + t.Fatalf("deleted files = %d, want 1", res.DeletedFiles) + } + mustNotExist(t, legacy) + mustExist(t, thumb) +} diff --git a/internal/service/cache_cleanup_test.go b/internal/service/cache_cleanup_test.go index 9b45c69..bcc5b2d 100644 --- a/internal/service/cache_cleanup_test.go +++ b/internal/service/cache_cleanup_test.go @@ -148,8 +148,8 @@ func TestSchedulerJobCleanImageCache(t *testing.T) { scheduler := NewSchedulerService(zap.NewNop(), nil, nil, nil, nil, nil, cacheRoot) // Set limit to 1MB; our file is 2MB -> should be pruned - scheduler.SetImagesMaxSizeMBProvider(func() int { - return 1 + scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy { + return ImageCachePolicy{TotalBytes: 1 * 1024 * 1024} }) if err := scheduler.jobCleanImageCache(context.Background()); err != nil { diff --git a/internal/service/emby_artwork.go b/internal/service/emby_artwork.go index f5efa64..35f007f 100644 --- a/internal/service/emby_artwork.go +++ b/internal/service/emby_artwork.go @@ -147,10 +147,18 @@ func (e *EmbyService) ImageInfos(ctx context.Context, id string) []map[string]an continue } seen[raw] = true + // ImageTag 让客户端判断自己缓存的图片是否还有效:远程条目的真实 + // ImageTags 会随远端换图变化,拿不到时才退化为条目 ID(恒定值)。 + tag := id + if e.remote != nil { + if remoteTag := e.remote.RemoteImageTagOfEncodedID(ctx, id, imageType); remoteTag != "" { + tag = remoteTag + } + } out = append(out, map[string]any{ "ImageType": imageType, "ImageIndex": 0, - "ImageTag": id, + "ImageTag": tag, }) } return out diff --git a/internal/service/emby_remote.go b/internal/service/emby_remote.go index 0b82e7b..0916bbd 100644 --- a/internal/service/emby_remote.go +++ b/internal/service/emby_remote.go @@ -79,6 +79,13 @@ type EmbyRemoteService struct { personMu sync.RWMutex personImages map[string]embyRemotePersonImageRef + + // imageTagMu 保护 imageTags:远程条目图片标签缓存,键为 + // imageTagKey(accountID, remoteID, imageType)。它让图片 URL 带上远端 + // ImageTags,从而在远端换图后让本地磁盘缓存与客户端缓存一起失效 + // (没有它时 URL 恒定,缩略图会永久停留在旧版本)。 + imageTagMu sync.RWMutex + imageTags map[string]string } type embyRemotePersonImageRef struct { @@ -86,6 +93,9 @@ type embyRemotePersonImageRef struct { remoteID string } +// embyRemoteMaxImageTags 限制图片标签映射的条目数,避免长期运行后无界增长。 +const embyRemoteMaxImageTags = 20000 + // NewEmbyRemoteService 构造远程 Emby 聚合服务。 func NewEmbyRemoteService(cfg *config.Config, log *zap.Logger, repo *repository.Container, crypto *CryptoService) *EmbyRemoteService { return &EmbyRemoteService{ @@ -819,6 +829,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou var cached map[string]any if r.cache.GetJSON(ctx, cacheKey, &cached) && len(cached) > 0 { r.rememberRemotePeople(mount, cached) + r.rememberRemoteImageTags(embyRemoteAccountID(acct), cached) return cached, nil } } @@ -833,6 +844,7 @@ func (r *EmbyRemoteService) RemoteItem(ctx context.Context, mount *model.EmbyMou return nil, err } r.rememberRemotePeople(mount, out) + r.rememberRemoteImageTags(embyRemoteAccountID(acct), out) RewriteEmbyRemoteIDs(out, mount.ID) if cacheKey != "" && len(out) > 0 { r.cache.SetJSON(ctx, cacheKey, out, r.remoteMediaCacheTTL()) @@ -1153,6 +1165,150 @@ func rewriteSubtitleDeliveryURLs(src map[string]any, playURL string, cfg *EmbyRe } } +// embyRemoteImageTagType 归一化图片类型:Emby 的 Art 与 Backdrop 指同一张图, +// 载荷里的 ImageTags 只会有 Primary / Backdrop 两个键。 +func embyRemoteImageTagType(imageType string) string { + switch strings.ToLower(strings.TrimSpace(imageType)) { + case "primary", "poster": + return "Primary" + case "backdrop", "art", "background": + return "Backdrop" + default: + return "" + } +} + +// remoteItemImageTag 从远程条目载荷读取某一类图片的原始 tag。载荷可能已被 +// RewriteEmbyRemoteIDs 伪装过(tag 变成 embyremote~scope~tag),此处会还原。 +func remoteItemImageTag(item map[string]any, imageType string) string { + typ := embyRemoteImageTagType(imageType) + if item == nil || typ == "" { + return "" + } + var raw string + switch tags := item["ImageTags"].(type) { + case map[string]any: + raw = anyString(tags[typ]) + case map[string]string: + raw = tags[typ] + } + if raw == "" && typ == "Backdrop" { + switch tags := item["BackdropImageTags"].(type) { + case []any: + if len(tags) > 0 { + raw = anyString(tags[0]) + } + case []string: + if len(tags) > 0 { + raw = tags[0] + } + } + } + if _, original, ok := DecodeEmbyRemoteID(raw); ok { + return original + } + return strings.TrimSpace(raw) +} + +// imageTagKey 是图片标签映射的键;不认识的图片类型返回空串(不记录)。 +func imageTagKey(accountID, remoteID, imageType string) string { + typ := embyRemoteImageTagType(imageType) + if typ == "" || strings.TrimSpace(remoteID) == "" || strings.TrimSpace(accountID) == "" { + return "" + } + return accountID + "|" + remoteID + "|" + typ +} + +// rememberRemoteImageTagValue 记录单条图片标签(供 SeriesPrimaryImageTag 这类 +// 散落在载荷其他字段里的标签使用)。 +func (r *EmbyRemoteService) rememberRemoteImageTagValue(accountID, remoteID, imageType, tag string) { + if r == nil || strings.TrimSpace(tag) == "" { + return + } + if _, original, ok := DecodeEmbyRemoteID(tag); ok { + tag = original + } + key := imageTagKey(accountID, remoteID, imageType) + if key == "" { + return + } + r.imageTagMu.Lock() + defer r.imageTagMu.Unlock() + if r.imageTags == nil || len(r.imageTags) > embyRemoteMaxImageTags { + r.imageTags = make(map[string]string, 256) + } + r.imageTags[key] = tag +} + +// rememberRemoteImageTags 记录载荷里出现的图片标签。载荷可以已被伪装。 +func (r *EmbyRemoteService) rememberRemoteImageTags(accountID string, item map[string]any) { + if r == nil || item == nil || strings.TrimSpace(accountID) == "" { + return + } + remoteID := remoteItemString(item, "Id") + if _, original, ok := DecodeEmbyRemoteID(remoteID); ok { + remoteID = original + } + if strings.TrimSpace(remoteID) == "" { + return + } + for _, imageType := range []string{"Primary", "Backdrop"} { + if tag := remoteItemImageTag(item, imageType); tag != "" { + r.rememberRemoteImageTagValue(accountID, remoteID, imageType, tag) + } + } +} + +// remoteImageTag 查询已记录的图片标签;未知时返回空串(调用方退化为原行为)。 +func (r *EmbyRemoteService) remoteImageTag(accountID, remoteID, imageType string) string { + if r == nil { + return "" + } + key := imageTagKey(accountID, remoteID, imageType) + if key == "" { + return "" + } + r.imageTagMu.RLock() + defer r.imageTagMu.RUnlock() + return r.imageTags[key] +} + +// remoteImageTagQuery 返回追加到远程图片地址后的 tag 查询片段(含 & 前缀)。 +// Emby 用 tag 作为图片 ETag/cache key:带上它之后,远端换图会改变 MeBox 的 +// 磁盘缓存键,缩略图与客户端缓存都会随之失效,而不是永久停留在旧版本。 +func (r *EmbyRemoteService) remoteImageTagQuery(accountID, remoteID, imageType string) string { + tag := r.remoteImageTag(accountID, remoteID, imageType) + if tag == "" { + return "" + } + return "&tag=" + url.QueryEscape(tag) +} + +// RemoteImageTagOfEncodedID 按伪装 ID 解析已记录的图片标签,供兼容层 +// 回报 ImageTag(客户端据此决定是否复用自己缓存的图片)。 +func (r *EmbyRemoteService) RemoteImageTagOfEncodedID(ctx context.Context, encodedID, imageType string) string { + if r == nil { + return "" + } + mountID, remoteID, ok := DecodeEmbyRemoteID(encodedID) + if !ok { + return "" + } + _, acct, _ := r.ResolveMount(ctx, mountID) + if acct == nil { + return "" + } + return r.remoteImageTag(acct.ID, remoteID, imageType) +} + +// embyRemoteAccountID 空值安全的账号 ID 读取(构建图片 URL 时可能只有账号对象)。 +func embyRemoteAccountID(acct *model.StrmAccount) string { + if acct == nil { + return "" + } + return acct.ID +} + // RemoteImageURL 构造远程图片绝对地址(由既有 ImageProxy 拉取透传)。 func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.StrmAccount, remoteID, imageType string) (string, error) { cfg, err := r.configOf(acct) @@ -1160,7 +1316,7 @@ func (r *EmbyRemoteService) RemoteImageURL(ctx context.Context, acct *model.Strm return "", err } return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(strings.ToLower(imageType)) + - "?api_key=" + url.QueryEscape(cfg.Token), nil + "?api_key=" + url.QueryEscape(cfg.Token) + r.remoteImageTagQuery(embyRemoteAccountID(acct), remoteID, imageType), nil } // rememberRemotePeople 记录远程人物名称到远程人物 ID 的映射,供旧式 diff --git a/internal/service/emby_remote_image_tag_test.go b/internal/service/emby_remote_image_tag_test.go new file mode 100644 index 0000000..e731199 --- /dev/null +++ b/internal/service/emby_remote_image_tag_test.go @@ -0,0 +1,173 @@ +package service + +import ( + "strings" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" + "github.com/truewhile/MeBox/internal/model" + "github.com/truewhile/MeBox/internal/repository" +) + +// newImageTagTestService 构造只用于图片标签映射测试的远程 Emby 服务。 +func newImageTagTestService(t *testing.T) (*EmbyRemoteService, *model.EmbyMount, *model.StrmAccount) { + t.Helper() + db := newServiceTestDB(t, &model.StrmAccount{}, &model.EmbyMount{}) + repos := repository.New(db) + svc := NewEmbyRemoteService(&config.Config{}, zap.NewNop(), repos, NewCryptoService("", zap.NewNop())) + acct := &model.StrmAccount{ + Base: model.Base{ID: "acct-tag"}, + Name: "tag-emby", + Provider: model.StrmProviderEmbyRemote, + Enabled: true, + Config: `{"url":"http://emby.test:8096","token":"fake-token"}`, + } + mount := &model.EmbyMount{ + Base: model.Base{ID: "mount-tag"}, + AccountID: acct.ID, + RemoteViewID: "view-tag", + Enabled: true, + } + if err := repos.StrmAccount.Create(t.Context(), acct); err != nil { + t.Fatalf("create account: %v", err) + } + if err := repos.EmbyMount.Create(t.Context(), mount); err != nil { + t.Fatalf("create mount: %v", err) + } + return svc, mount, acct +} + +// testRemoteImageConfig 是图片 URL 构造所需的最小远程配置。 +func testRemoteImageConfig() *EmbyRemoteConfig { + return &EmbyRemoteConfig{BaseURL: "http://emby.test:8096", Token: "fake-token"} +} + +// TestMapRemoteItemToMediaCarriesImageTags 下发的图片 URL 必须带上远端图片 +// 标签,否则远端换图后磁盘/客户端缓存永远不会失效。 +func TestMapRemoteItemToMediaCarriesImageTags(t *testing.T) { + svc, mount, acct := newImageTagTestService(t) + + media := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{ + "Id": "movie-1", + "Name": "测试影片", + "Type": "Movie", + "ImageTags": map[string]any{ + "Primary": "primary-tag-1", + }, + "BackdropImageTags": []any{"backdrop-tag-1"}, + }) + + if !strings.Contains(media.PosterURL, "tag=primary-tag-1") { + t.Fatalf("PosterURL missing remote image tag: %q", media.PosterURL) + } + if !strings.Contains(media.BackdropURL, "tag=backdrop-tag-1") { + t.Fatalf("BackdropURL missing remote image tag: %q", media.BackdropURL) + } +} + +// TestMapRemoteItemToMediaCarriesImageTagsAfterRewrite 载荷先经过 ID 伪装 +// (tag 变成 embyremote~...~tag)时仍要还原出原始 tag。 +func TestMapRemoteItemToMediaCarriesImageTagsAfterRewrite(t *testing.T) { + svc, mount, acct := newImageTagTestService(t) + + item := map[string]any{ + "Id": "movie-2", + "Name": "伪装过的影片", + "Type": "Movie", + "ImageTags": map[string]any{ + "Primary": "primary-tag-2", + }, + } + RewriteEmbyRemoteIDs(item, mount.ID) + + media := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), item) + if !strings.Contains(media.PosterURL, "tag=primary-tag-2") { + t.Fatalf("PosterURL missing decoded remote image tag: %q", media.PosterURL) + } + if strings.Contains(media.PosterURL, EmbyRemoteIDPrefix) { + t.Fatalf("PosterURL leaked disguised tag: %q", media.PosterURL) + } +} + +// TestRemoteImageURLCacheKeyFollowsImageTag 远端换图(tag 变化)后,图片 URL +// 必须随之变化,缓存键才会失效。 +func TestRemoteImageURLCacheKeyFollowsImageTag(t *testing.T) { + svc, mount, acct := newImageTagTestService(t) + + first := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{ + "Id": "movie-3", + "Name": "换图影片", + "Type": "Movie", + "ImageTags": map[string]any{"Primary": "old-tag"}, + }) + second := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{ + "Id": "movie-3", + "Name": "换图影片", + "Type": "Movie", + "ImageTags": map[string]any{"Primary": "new-tag"}, + }) + + if first.PosterURL == second.PosterURL { + t.Fatalf("image URL did not change when the remote tag changed: %q", first.PosterURL) + } + if imageCacheKeyURL(first.PosterURL) == imageCacheKeyURL(second.PosterURL) { + t.Fatal("image cache key did not change when the remote tag changed") + } +} + +// TestMapRemoteItemToMediaWithoutImageTagsKeepsURLTagFree 没有标签时保持原样 +// (不追加空 tag 参数)。 +func TestMapRemoteItemToMediaWithoutImageTagsKeepsURLTagFree(t *testing.T) { + svc, mount, acct := newImageTagTestService(t) + + media := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{ + "Id": "movie-4", + "Name": "无标签影片", + "Type": "Movie", + "ImageTags": map[string]any{"Primary": "primary-tag-4"}, + }) + if !strings.Contains(media.PosterURL, "tag=primary-tag-4") { + t.Fatalf("PosterURL missing tag: %q", media.PosterURL) + } + if strings.Contains(media.BackdropURL, "tag=") { + t.Fatalf("BackdropURL should not carry a tag when absent: %q", media.BackdropURL) + } + + unknown := svc.MapRemoteItemToMedia(t.Context(), mount, acct, testRemoteImageConfig(), map[string]any{ + "Id": "movie-5", + "Name": "未知标签影片", + "Type": "Movie", + "ImageTags": map[string]any{ + "Primary": "primary-tag-5", + }, + }) + if strings.Contains(unknown.BackdropURL, "tag=") { + t.Fatalf("unknown image tag should degrade to no tag: %q", unknown.BackdropURL) + } +} + +// TestRemoteImageURLIncludesRememberedTag 兼容层(/emby/Items/{id}/Images/...) +// 复用同一份标签映射。 +func TestRemoteImageURLIncludesRememberedTag(t *testing.T) { + svc, mount, acct := newImageTagTestService(t) + + svc.rememberRemoteImageTags(acct.ID, map[string]any{ + "Id": "movie-6", + "ImageTags": map[string]any{"Primary": "compat-tag"}, + }) + + raw, err := svc.RemoteImageURL(t.Context(), acct, "movie-6", "Primary") + if err != nil { + t.Fatalf("RemoteImageURL failed: %v", err) + } + if !strings.Contains(raw, "tag=compat-tag") { + t.Fatalf("remote image URL missing tag: %q", raw) + } + + encoded := EncodeEmbyRemoteID(mount.ID, "movie-6") + if got := svc.RemoteImageTagOfEncodedID(t.Context(), encoded, "Primary"); got != "compat-tag" { + t.Fatalf("RemoteImageTagOfEncodedID = %q, want compat-tag", got) + } +} diff --git a/internal/service/emby_remote_web.go b/internal/service/emby_remote_web.go index 35e486f..f573129 100644 --- a/internal/service/emby_remote_web.go +++ b/internal/service/emby_remote_web.go @@ -158,7 +158,7 @@ func (r *EmbyRemoteService) mapRemoteMountToLibrary(mount *model.EmbyMount, acct } // 远程媒体库封面只有真实存在图片标签才下发。 if remoteItemHasImageTag(item, "Primary") { - lib.CoverURL = r.remoteItemImageURL(cfg, mount.RemoteViewID, "Primary") + lib.CoverURL = r.remoteItemImageURL(acct, cfg, mount.RemoteViewID, "Primary") } return lib } @@ -176,6 +176,8 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod if _, rid, ok := DecodeEmbyRemoteID(remoteID); ok { remoteID = rid } + // 记录图片标签,使下发的图片 URL 带上 tag:远端换图后缓存随之失效。 + r.rememberRemoteImageTags(embyRemoteAccountID(acct), item) seriesID := remoteItemString(item, "SeriesId") if _, rid, ok := DecodeEmbyRemoteID(seriesID); ok { seriesID = rid @@ -214,10 +216,10 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod } // 只有远程明确存在图片标签才下发图片 URL。 if remoteItemHasImageTag(item, "Primary") { - media.PosterURL = r.remoteItemImageURL(cfg, remoteID, "Primary") + media.PosterURL = r.remoteItemImageURL(acct, cfg, remoteID, "Primary") } if remoteItemHasImageTag(item, "Backdrop") || len(remoteBackdropTags(item)) > 0 { - media.BackdropURL = r.remoteItemImageURL(cfg, remoteID, "Backdrop") + media.BackdropURL = r.remoteItemImageURL(acct, cfg, remoteID, "Backdrop") } if ticks := remoteItemInt64(item, "RunTimeTicks"); ticks > 0 { media.DurationSec = int(ticks / 10_000_000) @@ -307,9 +309,11 @@ func (r *EmbyRemoteService) MapRemoteItemToMedia(ctx context.Context, mount *mod } // 单集通常无独立海报:若远程返回 SeriesPrimaryImageTag(需要 // Fields=SeriesPrimaryImage)且系列有图,则回退到系列海报。 - if media.PosterURL == "" && seriesID != "" && - strings.TrimSpace(remoteItemString(item, "SeriesPrimaryImageTag")) != "" { - media.PosterURL = r.remoteItemImageURL(cfg, seriesID, "Primary") + if media.PosterURL == "" && seriesID != "" { + if seriesTag := strings.TrimSpace(remoteItemString(item, "SeriesPrimaryImageTag")); seriesTag != "" { + r.rememberRemoteImageTagValue(embyRemoteAccountID(acct), seriesID, "Primary", seriesTag) + media.PosterURL = r.remoteItemImageURL(acct, cfg, seriesID, "Primary") + } } default: // Movie / Series / Season / Folder media.SeasonNum = 0 @@ -914,8 +918,9 @@ func remoteItemGenres(item map[string]any) string { return strings.Join(parts, ",") } -// remoteItemImageURL 构造远程条目图片绝对地址(带 api_key;前端经 /api/img 代理)。 -func (r *EmbyRemoteService) remoteItemImageURL(cfg *EmbyRemoteConfig, remoteID, imageType string) string { +// remoteItemImageURL 构造远程条目图片绝对地址(带 api_key 与图片 tag;前端经 +// /api/img 代理)。tag 来自载荷的 ImageTags,用于远端换图后缓存失效。 +func (r *EmbyRemoteService) remoteItemImageURL(acct *model.StrmAccount, cfg *EmbyRemoteConfig, remoteID, imageType string) string { if remoteID == "" { return "" } @@ -924,7 +929,7 @@ func (r *EmbyRemoteService) remoteItemImageURL(cfg *EmbyRemoteConfig, remoteID, imageType = "primary" } return r.embyBase(cfg) + "/Items/" + url.PathEscape(remoteID) + "/Images/" + url.PathEscape(imageType) + - "?api_key=" + url.QueryEscape(cfg.Token) + "?api_key=" + url.QueryEscape(cfg.Token) + r.remoteImageTagQuery(embyRemoteAccountID(acct), remoteID, imageType) } // remoteItemHasImageTag 远程 item 是否带某类型图片标签(Emby 的 ImageTags map)。 diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index d742234..91a7a52 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -71,6 +71,12 @@ const ( imageBrowserCacheControl = "public, max-age=2592000, immutable" imagePlaceholderCacheControl = "no-store" imageMaxResizeConcurrency = 4 + + // imageOriginalCacheSubdir 存放上游原图(生成各种尺寸的原料,可再生)。 + imageOriginalCacheSubdir = "originals" + // imageRenditionCacheSubdir 存放挂载 Emby 直接按尺寸产出的成品图:它们 + // 已经是客户端要的最终尺寸,和派生缩略图一样属于长期保留的热路径缓存。 + imageRenditionCacheSubdir = "renditions" ) // NewImageProxy is the constructor. @@ -214,10 +220,20 @@ 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 } - maxBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024 - return PruneImageCache(p.cacheDir, maxBytes) + var originalsMaxBytes int64 + 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) + return PruneImageCachePools(pools, totalBytes) } diff --git a/internal/service/image_proxy_cache.go b/internal/service/image_proxy_cache.go index 327129f..b3194ef 100644 --- a/internal/service/image_proxy_cache.go +++ b/internal/service/image_proxy_cache.go @@ -4,6 +4,8 @@ import ( "bytes" "crypto/sha256" "encoding/hex" + "errors" + "fmt" "io" "net/http" "os" @@ -68,17 +70,7 @@ func serveCachedPlaceholder(w http.ResponseWriter) { _, _ = w.Write(transparent1x1PNG) } -func (p *ImageProxy) cloudImageCachePaths(stableKey string) (string, string, string) { - stableKey = strings.TrimSpace(stableKey) - if stableKey == "" { - stableKey = "unknown" - } - sum := sha256.Sum256([]byte("cloud-image:" + stableKey)) - key := "cloud-" + hex.EncodeToString(sum[:]) - cachePath := filepath.Join(p.cacheDir, key) - return key, cachePath, cachePath + ".fail" -} - +// remoteImageCachePaths 返回上游原图池的路径(带 URL 校验)。 func (p *ImageProxy) remoteImageCachePaths(raw string) (string, string, string, error) { if _, err := p.validateURL(raw); err != nil { return "", "", "", err @@ -87,13 +79,120 @@ func (p *ImageProxy) remoteImageCachePaths(raw string) (string, string, string, return key, cachePath, failPath, nil } +// remoteImageCachePathsForValidated 返回上游原图池的缓存路径。 func (p *ImageProxy) remoteImageCachePathsForValidated(raw string) (string, string, string) { + return p.remoteImageCachePathsInPool(raw, imageOriginalCacheSubdir) +} + +// remoteImageCachePathsInPool 生成某个池内的缓存路径。池决定清理策略: +// 原图池小配额 + 短保留,成品池(挂载 Emby 按尺寸产出的图)长期保留。 +func (p *ImageProxy) remoteImageCachePathsInPool(raw, pool string) (string, string, string) { sum := sha256.Sum256([]byte(imageCacheKeyURL(raw))) key := hex.EncodeToString(sum[:]) - cachePath := filepath.Join(p.cacheDir, key) + cachePath := filepath.Join(p.cacheDir, pool, key) return key, cachePath, cachePath + ".fail" } +// remoteImageCachePathsEveryPool 返回同一个地址在所有池中的缓存路径。调用方 +// 只知道原始 URL,无法判断它是原图还是挂载 Emby 按尺寸返回的成品,因此清理 +// 类操作(refresh/retry)需要两个池都试一遍。 +func (p *ImageProxy) remoteImageCachePathsEveryPool(raw string) [][3]string { + pools := []string{imageOriginalCacheSubdir, imageRenditionCacheSubdir} + out := make([][3]string, 0, len(pools)) + for _, pool := range pools { + key, cachePath, failPath := p.remoteImageCachePathsInPool(raw, pool) + out = append(out, [3]string{key, cachePath, failPath}) + } + return out +} + +// imageProxyMaxDownloadBytes 是单张图片的下载上限,防上游异常返回超大响应 +// 把磁盘和内存打满。 +const imageProxyMaxDownloadBytes = 32 << 20 + +var ( + // errImageCacheUnavailable 表示缓存目录不可写、连临时文件都建不出来。 + // 它发生在读取响应体之前,因此调用方还能退回内存缓冲,保证图片不因 + // 运维异常(磁盘只读/满)全部变成占位图。 + errImageCacheUnavailable = errors.New("image cache is not writable") + // errImageCacheCommitFailed 表示响应体已被消费但无法提交到缓存(写入 + // 中断、rename 失败等),此时无法重放响应体,只能按拉取失败处理。 + errImageCacheCommitFailed = errors.New("image cache commit failed") +) + +// cachedImageFileValid 只读取文件头判断缓存文件是否是可用图片。透明占位图 +// 与空文件都视为不可用(历史实现曾把上游失败时的占位图写进缓存)。 +func cachedImageFileValid(path string) error { + file, err := os.Open(path) // #nosec G304 -- cache paths are SHA-derived under cacheDir. + if err != nil { + return err + } + defer file.Close() + stat, err := file.Stat() + if err != nil || stat.IsDir() || stat.Size() <= 0 { + return errImageProxyNonImageContent + } + headerSize := 512 + if stat.Size() < int64(headerSize) { + headerSize = int(stat.Size()) + } + header := make([]byte, headerSize) + n, readErr := io.ReadFull(file, header) + if readErr != nil && !errors.Is(readErr, io.ErrUnexpectedEOF) { + return readErr + } + header = header[:n] + if !isImageContentType(detectContentType(header)) { + return errImageProxyNonImageContent + } + // 透明占位图恰好 67 字节;只看头部就够(detectContentType 也只读前 512 字节)。 + if n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG) { + return errImageProxyNonImageContent + } + return nil +} + +// streamImageToCache 把上游响应体流式写入临时文件,校验确为图片后原子替换到 +// cachePath。原图常有数兆字节,旧实现每次都要先整张读进内存再写盘,在小内存 +// 主机上几个并发海报请求就能把内存顶满。 +func (p *ImageProxy) streamImageToCache(cachePath, failPath string, body io.Reader) error { + if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { + p.warn("imageproxy: cache dir failed", err) + return errImageCacheUnavailable + } + tmp, err := os.CreateTemp(p.cacheDir, "img-*.tmp") + if err != nil { + p.warn("imageproxy: cache temp file failed", err) + return errImageCacheUnavailable + } + tmpName := tmp.Name() + discard := func() { _ = os.Remove(tmpName) } + + written, copyErr := io.Copy(tmp, io.LimitReader(body, imageProxyMaxDownloadBytes)) + closeErr := tmp.Close() + if copyErr != nil || closeErr != nil { + discard() + if copyErr != nil { + return copyErr + } + return fmt.Errorf("%w: %v", errImageCacheCommitFailed, closeErr) + } + if written == 0 { + discard() + return errors.New("upstream image body is empty") + } + if err := cachedImageFileValid(tmpName); err != nil { + discard() + return err + } + if err := os.Rename(tmpName, cachePath); err != nil { + discard() + return fmt.Errorf("%w: %v", errImageCacheCommitFailed, err) + } + _ = os.Remove(failPath) + return nil +} + func serveCachedImageFile(w http.ResponseWriter, r *http.Request, key, cachePath string) bool { return serveImageFile(w, r, key, cachePath, imageBrowserCacheControl) } @@ -131,13 +230,6 @@ func imageFileETag(key string, stat os.FileInfo) string { return `"img-` + hex.EncodeToString(sum[:8]) + "-" + strconv.FormatInt(stat.Size(), 16) + "-" + strconv.FormatInt(stat.ModTime().Unix(), 16) + `"` } -func freshNegativeImageCache(failPath string) bool { - if _, err := os.Stat(failPath); err == nil { - _ = os.Remove(failPath) - } - return false -} - func (p *ImageProxy) markImageFetchFailed(failPath string) { if err := os.MkdirAll(filepath.Dir(failPath), 0o750); err != nil { return diff --git a/internal/service/image_proxy_remote.go b/internal/service/image_proxy_remote.go index 323eb5b..69468ee 100644 --- a/internal/service/image_proxy_remote.go +++ b/internal/service/image_proxy_remote.go @@ -4,53 +4,80 @@ import ( "bytes" "context" "errors" - "io" "net/http" "os" "path/filepath" "strings" "time" - - "go.uber.org/zap" ) var errImageProxyRequestSetup = errors.New("image proxy request setup failed") var errImageProxyNonImageContent = errors.New("upstream returned non-image content") +// prefetchCardResizeOptions 对应前端 ARTWORK.posterCard(见 web/src/api/client.ts): +// 卡片是海报墙最常请求的档位,刮削阶段预生成它能让首个列表请求直接命中缓存。 +// 若前端调整该预设,这里只是白生成一份用不到的档位(约几十 KB),不影响正确性。 +var prefetchCardResizeOptions = imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80} + func (p *ImageProxy) PrefetchRemote(ctx context.Context, raw string) error { _, _, err := p.Fetch(ctx, raw) return err } +// PrefetchCardVariant 预取原图后再离线生成卡片档位的缩略图。刮削是后台任务, +// 在这里做掉解码可以把海报墙首屏的 CPU 抖动移到请求路径之外。 +// +// 预生成失败不影响预取结果:预取的成功含义是「图片可达」(刮削据此决定是否 +// 替换旧图),而缩略图只是优化,客户端首次请求时会自己生成。 +func (p *ImageProxy) PrefetchCardVariant(ctx context.Context, raw string) error { + if err := p.PrefetchRemote(ctx, raw); err != nil { + return err + } + if !isHTTPish(raw) { + return nil + } + _, cachePath, _ := p.remoteImageCachePathsForValidated(raw) + if err := p.ensureResizeCache(ctx, cachePath, prefetchCardResizeOptions); err != nil { + p.warn("imageproxy: prefetch card variant failed", err) + } + return nil +} + func (p *ImageProxy) RemoveCached(raw string) error { if !isHTTPish(raw) { return nil } - _, cachePath, failPath, err := p.remoteImageCachePaths(raw) - if err != nil { + if _, err := p.validateURL(raw); err != nil { return nil } - if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err + var firstErr error + for _, paths := range p.remoteImageCachePathsEveryPool(raw) { + cachePath, failPath := paths[1], paths[2] + if err := os.Remove(cachePath); err != nil && !errors.Is(err, os.ErrNotExist) { + firstErr = err + } + if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) { + firstErr = err + } } - if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - return nil + return firstErr } func (p *ImageProxy) RemoveFailed(raw string) error { if !isHTTPish(raw) { return nil } - _, _, failPath, err := p.remoteImageCachePaths(raw) - if err != nil { + if _, err := p.validateURL(raw); err != nil { return nil } - if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return err + var firstErr error + for _, paths := range p.remoteImageCachePathsEveryPool(raw) { + failPath := paths[2] + if err := os.Remove(failPath); err != nil && !errors.Is(err, os.ErrNotExist) { + firstErr = err + } } - return nil + return firstErr } // Serve writes the requested image to w. Caller is expected to validate @@ -90,7 +117,14 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter // 已配置的远程 Emby 挂载:把尺寸直接转发给远端生成缩略图,缓存键也用 // 带尺寸的地址,这样不同尺寸各自缓存互不覆盖。 fetchURL := p.upstreamImageFetchURL(raw, opts) - key, cachePath, failPath := p.remoteImageCachePathsForValidated(fetchURL) + // 尺寸被转发给挂载的远端时,上游返回的就是客户端要的最终尺寸成品,归入 + // 成品池长期保留;其他上游返回的是原图,只是生成各种尺寸的原料,归入 + // 原图池(小配额 + 短保留)。 + pool := imageOriginalCacheSubdir + if fetchURL != raw { + pool = imageRenditionCacheSubdir + } + key, cachePath, failPath := p.remoteImageCachePathsInPool(fetchURL, pool) forceRefresh := r.URL.Query().Get("refresh") != "" p.removeUnusableImageCache(cachePath, failPath) if !forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) { @@ -98,7 +132,7 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter } // No negative caching: a previously failed fetch is retried on every // subsequent request, so the image recovers as soon as upstream does. - data, ctype, contentLength, err := p.fetchAndCacheRemoteImageShared(ctx, fetchURL, host, cachePath, failPath) + result, err := p.fetchAndCacheRemoteImageShared(ctx, fetchURL, host, cachePath, failPath) if err != nil { if forceRefresh && p.serveCachedImage(w, r, key, cachePath, opts) { return nil @@ -114,17 +148,16 @@ func (p *ImageProxy) serveRemoteImage(ctx context.Context, w http.ResponseWriter if opts.active() && p.serveResizedFromFile(w, r, cachePath, opts) { return nil } - w.Header().Set("Content-Type", ctype) - if contentLength != "" { - w.Header().Set("Content-Length", contentLength) + // 缓存目录不可写时的内存兜底:图片只在本次响应里直出,不落盘。 + if len(result.data) > 0 { + w.Header().Set("Content-Type", result.contentType) + w.Header().Set("Cache-Control", imageBrowserCacheControl) + http.ServeContent(w, r, key, time.Now(), bytes.NewReader(result.data)) + return nil } - modTime := time.Now() - if stat, err := os.Stat(cachePath); err == nil && stat.Size() > 0 { - modTime = stat.ModTime() - w.Header().Set("ETag", imageFileETag(key, stat)) + if !p.serveCachedImage(w, r, key, cachePath, opts) { + serveCachedPlaceholder(w) } - w.Header().Set("Cache-Control", imageBrowserCacheControl) - http.ServeContent(w, r, key, modTime, bytes.NewReader(data)) return nil } @@ -140,64 +173,32 @@ func (p *ImageProxy) serveCachedImage(w http.ResponseWriter, r *http.Request, ke func (p *ImageProxy) removeUnusableImageCache(cachePath, failPath string) { // 只读取文件头判断缓存是否可用。旧实现每次命中远程图片缓存都会把整个 // 原图读进内存再丢弃,电视端批量加载海报时会产生大量无意义的磁盘 I/O。 - file, err := os.Open(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir. - if err != nil { + if _, err := os.Stat(cachePath); err != nil { return } - stat, err := file.Stat() - if err != nil || stat.IsDir() || stat.Size() <= 0 { - _ = file.Close() + if err := cachedImageFileValid(cachePath); err != nil { _ = os.Remove(cachePath) _ = os.Remove(failPath) - return } - headerSize := 512 - if stat.Size() < int64(headerSize) { - headerSize = int(stat.Size()) - } - header := make([]byte, headerSize) - n, readErr := io.ReadFull(file, header) - _ = file.Close() - if readErr != nil && readErr != io.ErrUnexpectedEOF { - _ = os.Remove(cachePath) - _ = os.Remove(failPath) - return - } - header = header[:n] - // A transparent placeholder is exactly 67 bytes; checking the header alone - // is enough for the normal image cache entries (they are much larger but - // detectContentType only inspects the same leading 512 bytes anyway). - // Close the handle before deleting: Windows refuses to delete an open file. - if n > 0 && isImageContentType(detectContentType(header)) && - !(n == len(transparent1x1PNG) && bytes.Equal(header, transparent1x1PNG)) { - return - } - _ = os.Remove(cachePath) - _ = os.Remove(failPath) } -func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) { - if err := os.MkdirAll(p.cacheDir, 0o750); err != nil { - p.log.Warn("imageproxy: mkdir failed", zap.String("dir", p.cacheDir), zap.Error(err)) - return nil, "", "", errImageProxyRequestSetup - } +func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) { var lastErr error for _, candidate := range p.remoteImageFetchClients(host) { - data, ctype, contentLength, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate) + result, err := p.fetchRemoteImageOnce(ctx, raw, host, candidate, cachePath, failPath) if err == nil { - p.writeImageCache(cachePath, failPath, "img-*.tmp", data) - return data, ctype, contentLength, nil + return result, nil } if errors.Is(err, errImageProxyRequestSetup) { - return nil, "", "", err + return remoteImageFetchResult{}, err } lastErr = err } if p.canUseExternalImageFallback() && isDoubanImageHost(host) { - data, ctype, contentLength, err := fetchRemoteImageWithCurl(ctx, raw, host) + data, ctype, _, err := fetchRemoteImageWithCurl(ctx, raw, host) if err == nil { p.writeImageCache(cachePath, failPath, "img-*.tmp", data) - return data, ctype, contentLength, nil + return remoteImageFetchResult{data: data, contentType: ctype}, nil } logImageFetchError(p.log, "imageproxy: curl fallback failed", host, "curl", err) lastErr = err @@ -206,41 +207,27 @@ func (p *ImageProxy) fetchAndCacheRemoteImage(ctx context.Context, raw, host, ca if lastErr == nil { lastErr = errors.New("upstream image fetch failed") } - return nil, "", "", redactSensitiveError(lastErr) -} - -type sharedRemoteImageResult struct { - data []byte - contentType string - contentLength string + return remoteImageFetchResult{}, redactSensitiveError(lastErr) } // fetchAndCacheRemoteImageShared coalesces concurrent requests for the same // upstream image. A poster can appear in the hero, a shelf and the detail page // at the same time; without this guard every resize variant may fetch the same // original before the first cache write finishes. -func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) ([]byte, string, string, error) { +func (p *ImageProxy) fetchAndCacheRemoteImageShared(ctx context.Context, raw, host, cachePath, failPath string) (remoteImageFetchResult, error) { value, err, _ := p.fetchGroup.Do(cachePath, func() (any, error) { loadCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 45*time.Second) defer cancel() - data, contentType, contentLength, err := p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath) - if err != nil { - return nil, err - } - return sharedRemoteImageResult{ - data: data, - contentType: contentType, - contentLength: contentLength, - }, nil + return p.fetchAndCacheRemoteImage(loadCtx, raw, host, cachePath, failPath) }) if err != nil { - return nil, "", "", err + return remoteImageFetchResult{}, err } - result, ok := value.(sharedRemoteImageResult) + result, ok := value.(remoteImageFetchResult) if !ok { - return nil, "", "", errors.New("upstream image fetch failed") + return remoteImageFetchResult{}, errors.New("upstream image fetch failed") } - return result.data, result.contentType, result.contentLength, nil + return result, nil } // Fetch pulls a remote image and returns bytes plus Content-Type using cache. @@ -262,8 +249,19 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err } // No negative caching: a previously failed fetch is retried on every // subsequent request, so the image recovers as soon as upstream does. - data, ctype, _, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath) - return data, ctype, err + result, err := p.fetchAndCacheRemoteImage(ctx, raw, host, cachePath, failPath) + if err != nil { + return nil, "", err + } + // 正常路径只落盘,这里按需读回(调用方需要字节)。 + if len(result.data) > 0 { + return result.data, result.contentType, nil + } + data, err := os.ReadFile(cachePath) // #nosec G304 -- cachePath is SHA-derived under cacheDir. + if err != nil || len(data) == 0 { + return nil, "", errors.New("cached image is unreadable") + } + return data, detectContentType(data), nil } // writeImageCache atomically writes the fetched original. The global mutex is @@ -271,6 +269,9 @@ func (p *ImageProxy) Fetch(ctx context.Context, raw string) ([]byte, string, err // os.Rename is atomic, so the lock only serialized multi-megabyte disk writes // and made one poster's write block every other image in flight. func (p *ImageProxy) writeImageCache(cachePath, failPath, pattern string, data []byte) { + if err := os.MkdirAll(filepath.Dir(cachePath), 0o750); err != nil { + return + } tmp, tmpErr := os.CreateTemp(p.cacheDir, pattern) if tmpErr != nil { return diff --git a/internal/service/image_proxy_remote_fetch.go b/internal/service/image_proxy_remote_fetch.go index ebfe6ee..29957c6 100644 --- a/internal/service/image_proxy_remote_fetch.go +++ b/internal/service/image_proxy_remote_fetch.go @@ -54,38 +54,63 @@ func (p *ImageProxy) canUseExternalImageFallback() bool { return ok } -func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient) ([]byte, string, string, error) { +// remoteImageFetchResult 是一次成功拉取的结果。正常路径图片已经流式落盘 +// (data 为空,调用方从缓存文件下发/缩放);只有缓存目录不可写、退回内存 +// 缓冲时才带 data,保证图片仍能发给客户端。 +type remoteImageFetchResult struct { + data []byte + contentType string +} + +// fetchRemoteImageOnce 拉取一次上游图片并写入 cachePath。响应体直接流式落盘, +// 不再整张读进内存。 +func (p *ImageProxy) fetchRemoteImageOnce(ctx context.Context, raw, host string, candidate remoteImageFetchClient, cachePath, failPath string) (remoteImageFetchResult, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) if err != nil { p.log.Warn("imageproxy: build request failed", zap.String("url", redactSensitiveURL(raw)), zap.Error(redactSensitiveError(err))) - return nil, "", "", errImageProxyRequestSetup + return remoteImageFetchResult{}, errImageProxyRequestSetup } applyRemoteImageHeaders(req, host, raw) resp, err := candidate.client.Do(req) if err != nil { logImageFetchError(p.log, "imageproxy: upstream fetch failed", host, candidate.name, err) - return nil, "", "", err + return remoteImageFetchResult{}, err } defer resp.Body.Close() if resp.StatusCode >= 400 { p.log.Warn("imageproxy: upstream returned non-OK", zap.String("host", host), zap.String("client", candidate.name), zap.String("status", resp.Status)) - return nil, "", "", errors.New("upstream returned " + resp.Status) + return remoteImageFetchResult{}, errors.New("upstream returned " + resp.Status) } - data, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20)) + if err := p.streamImageToCache(cachePath, failPath, resp.Body); err != nil { + if errors.Is(err, errImageCacheUnavailable) { + // 缓存目录不可写:响应体还没读,退回内存缓冲。 + return p.bufferRemoteImage(resp, host, candidate.name, cachePath, failPath) + } + logImageFetchError(p.log, "imageproxy: stream image failed", host, candidate.name, err) + return remoteImageFetchResult{}, err + } + return remoteImageFetchResult{}, nil +} + +// bufferRemoteImage 在缓存不可用时把响应体读进内存,校验为图片后尽力写入 +// 缓存(失败也不影响本次下发)。 +func (p *ImageProxy) bufferRemoteImage(resp *http.Response, host, client, cachePath, failPath string) (remoteImageFetchResult, error) { + data, err := io.ReadAll(io.LimitReader(resp.Body, imageProxyMaxDownloadBytes)) if err != nil || len(data) == 0 { - p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", candidate.name), zap.Error(redactSensitiveError(err))) + p.log.Warn("imageproxy: read upstream body failed", zap.String("host", host), zap.String("client", client), zap.Error(redactSensitiveError(err))) if err == nil { err = errors.New("upstream image body is empty") } - return nil, "", "", err + return remoteImageFetchResult{}, err } ctype, ok := validImageContentType(data) if !ok { - p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", candidate.name), zap.String("content_type", resp.Header.Get("Content-Type"))) - return nil, "", "", errImageProxyNonImageContent + p.log.Warn("imageproxy: upstream returned non-image content", zap.String("host", host), zap.String("client", client), zap.String("content_type", resp.Header.Get("Content-Type"))) + return remoteImageFetchResult{}, errImageProxyNonImageContent } - return data, ctype, resp.Header.Get("Content-Length"), nil + p.writeImageCache(cachePath, failPath, "img-*.tmp", data) + return remoteImageFetchResult{data: data, contentType: ctype}, nil } func logImageFetchError(log *zap.Logger, message, host, client string, err error) { diff --git a/internal/service/image_proxy_stream_test.go b/internal/service/image_proxy_stream_test.go new file mode 100644 index 0000000..24ec8ee --- /dev/null +++ b/internal/service/image_proxy_stream_test.go @@ -0,0 +1,188 @@ +package service + +import ( + "bytes" + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "sync/atomic" + "testing" + + "go.uber.org/zap" + + "github.com/truewhile/MeBox/internal/config" +) + +// newStreamImageProxy 构造一个允许访问指定测试上游的图片代理。 +func newStreamImageProxy(t *testing.T, upstreamURL string) *ImageProxy { + t.Helper() + parsed, err := url.Parse(upstreamURL) + if err != nil { + t.Fatalf("parse upstream url: %v", err) + } + proxy := NewImageProxy(&config.Config{Cache: config.CacheConfig{CacheDir: filepath.Join(t.TempDir(), "cache")}}, zap.NewNop()) + proxy.SetAllowedRemoteHostsProvider(func() []string { return []string{parsed.Host} }) + return proxy +} + +// bigTestJPEG 在合法 JPEG 后面补一串数据,用来验证大图是流式落盘而不是 +// 整张读进内存(剧照/原图常有数兆字节)。 +func bigTestJPEG(t *testing.T, extraBytes int) []byte { + t.Helper() + out := make([]byte, 0, len(testJPEG)+extraBytes) + out = append(out, testJPEG...) + out = append(out, bytes.Repeat([]byte{0x5a}, extraBytes)...) + return out +} + +// TestImageProxyStreamsRemoteImageIntoCacheFile 远程原图必须直接流式写到 +// 缓存文件,后续请求由缓存文件服务(上游只被请求一次)。 +func TestImageProxyStreamsRemoteImageIntoCacheFile(t *testing.T) { + payload := bigTestJPEG(t, 3<<20) + + var calls int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(payload) + })) + defer upstream.Close() + + proxy := newStreamImageProxy(t, upstream.URL) + raw := upstream.URL + "/poster.jpg" + + rec := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil { + t.Fatalf("Serve failed: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if !bytes.Equal(rec.Body.Bytes(), payload) { + t.Fatalf("body length = %d, want %d", rec.Body.Len(), len(payload)) + } + + _, cachePath, failPath := proxy.remoteImageCachePathsForValidated(raw) + stat, err := os.Stat(cachePath) + if err != nil { + t.Fatalf("expected the image to be streamed to %s: %v", cachePath, err) + } + if stat.Size() != int64(len(payload)) { + t.Fatalf("cached size = %d, want %d", stat.Size(), len(payload)) + } + if _, err := os.Stat(failPath); err == nil { + t.Fatalf("unexpected failure marker at %s", failPath) + } + if entries, err := os.ReadDir(filepath.Dir(cachePath)); err == nil { + for _, entry := range entries { + if filepath.Ext(entry.Name()) == ".tmp" { + t.Fatalf("temporary file left behind: %s", entry.Name()) + } + } + } + + // 第二次请求命中缓存文件,不再回源。 + second := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), second, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil { + t.Fatalf("second Serve failed: %v", err) + } + if !bytes.Equal(second.Body.Bytes(), payload) { + t.Fatal("cached response body mismatch") + } + if got := atomic.LoadInt32(&calls); got != 1 { + t.Fatalf("upstream calls = %d, want 1 (second request must be served from cache)", got) + } +} + +// TestImageProxyRejectsNonImageUpstreamWithoutCaching 流式写入必须在提交前 +// 校验内容,否则错误页会被永久缓存成图片。 +func TestImageProxyRejectsNonImageUpstreamWithoutCaching(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = w.Write([]byte("not an image")) + })) + defer upstream.Close() + + proxy := newStreamImageProxy(t, upstream.URL) + raw := upstream.URL + "/poster.jpg" + + rec := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil { + t.Fatalf("Serve failed: %v", err) + } + if rec.Body.Len() != len(transparent1x1PNG) { + t.Fatalf("body length = %d, want placeholder %d", rec.Body.Len(), len(transparent1x1PNG)) + } + + _, cachePath, failPath := proxy.remoteImageCachePathsForValidated(raw) + if _, err := os.Stat(cachePath); err == nil { + t.Fatal("non-image response must not be cached") + } + if _, err := os.Stat(failPath); err != nil { + t.Fatalf("expected a failure marker next to the cache entry: %v", err) + } +} + +// TestImageProxyServesRemoteImageWhenCacheDirUnwritable 缓存目录不可写时 +// (磁盘只读/满)必须退回内存缓冲,不能让所有图片变成占位图。 +func TestImageProxyServesRemoteImageWhenCacheDirUnwritable(t *testing.T) { + payload := testJPEG + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(payload) + })) + defer upstream.Close() + + // 把一个普通文件当作目录的父级,MkdirAll 必然失败。 + root := t.TempDir() + blocker := filepath.Join(root, "blocker") + if err := os.WriteFile(blocker, []byte("x"), 0o644); err != nil { + t.Fatalf("write blocker: %v", err) + } + proxy := newStreamImageProxy(t, upstream.URL) + proxy.cacheDir = filepath.Join(blocker, "cache", "images") + + raw := upstream.URL + "/poster.jpg" + rec := httptest.NewRecorder() + if err := proxy.Serve(t.Context(), rec, httptest.NewRequest(http.MethodGet, "/api/img", nil), raw); err != nil { + t.Fatalf("Serve failed: %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + body, err := io.ReadAll(rec.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + if !bytes.Equal(body, payload) { + t.Fatalf("body length = %d, want %d", len(body), len(payload)) + } +} + +// TestImageProxyFetchReturnsCachedBytes Fetch 仍需返回字节(刮削写元数据用), +// 内容来自刚写入的缓存文件。 +func TestImageProxyFetchReturnsCachedBytes(t *testing.T) { + payload := bigTestJPEG(t, 256<<10) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(payload) + })) + defer upstream.Close() + + proxy := newStreamImageProxy(t, upstream.URL) + raw := upstream.URL + "/poster.jpg" + + data, ctype, err := proxy.Fetch(t.Context(), raw) + if err != nil { + t.Fatalf("Fetch failed: %v", err) + } + if !bytes.Equal(data, payload) { + t.Fatalf("fetched %d bytes, want %d", len(data), len(payload)) + } + if ctype != "image/jpeg" { + t.Fatalf("content type = %q, want image/jpeg", ctype) + } +} diff --git a/internal/service/image_resize.go b/internal/service/image_resize.go index 1b6765d..63b7a8d 100644 --- a/internal/service/image_resize.go +++ b/internal/service/image_resize.go @@ -34,7 +34,16 @@ const ( // N 像素的图在内存中约需 4N 字节;没有上限时,一张异常的超大图 // 就能在多张并发缩略图请求下打爆小内存主机。超过该上限时直接 // 回退为原图直出,宁可不缩放也不冒 OOM 风险。 - imageResizeMaxSourcePixels = 30_000_000 + // + // 1200 万像素覆盖常见的高清海报(2892×4096 约 1180 万),单张解码 + // 峰值约 48MB RGBA;原先的 3000 万在 2 核 2GB 的机器上意味着单张 + // 就可能吃掉 120MB 以上,两个并发槽足以触发 OOM/大量换页。 + imageResizeMaxSourcePixels = 12_000_000 + + // imageResizeCheapScaleRatio 是启用低成本插值的缩小比例阈值:目标尺寸 + // 小于源图一半时,CatmullRom 的收益肉眼不可见,但耗时和临时缓冲明显 + // 更高(电视端海报墙会同时请求几十张缩略图)。 + imageResizeCheapScaleRatio = 0.5 // imageResizeCacheSubdir 存放缩放结果,与远程原图缓存分开放, // 便于单独清理且不与原始字节流缓存互相覆盖。 @@ -156,7 +165,7 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin return nil, "", false, err } dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) - draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil) + resizeScaled(dst, src, cfg.Width, cfg.Height) // 只有可能带透明的源格式才需要逐像素确认,避免 JPEG 的无谓遍历。 // 写实海报的 PNG 通常比等价 JPEG 大一个数量级,因此在确认不含透明 @@ -176,12 +185,42 @@ func resizeImageData(data []byte, o imageResizeOptions) (out []byte, ctype strin return buf.Bytes(), "image/jpeg", false, nil } -// isOpaqueImage 逐像素确认图像不含透明像素。 +// useCheapScaleInterpolator 报告这次缩放是否该用低成本插值:目标尺寸在宽高 +// 两个方向都缩到源图一半以下时,CatmullRom 的额外采样换来的观感差异不可见。 +func useCheapScaleInterpolator(dstW, dstH, srcW, srcH int) bool { + if srcW <= 0 || srcH <= 0 || dstW <= 0 || dstH <= 0 { + return false + } + return float64(dstW)/float64(srcW) <= imageResizeCheapScaleRatio && + float64(dstH)/float64(srcH) <= imageResizeCheapScaleRatio +} + +// resizeScaled 把 src 缩放到 dst。大幅缩小时改用低成本插值:CatmullRom +// 与 ApproxBiLinear 在大比例缩小下观感差异看不出来,但前者要遍历更多 +// 邻域样本,在 2 核机器上会明显拖慢海报墙的并发缩略图请求。 +func resizeScaled(dst *image.RGBA, src image.Image, srcW, srcH int) { + if dst == nil { + return + } + bounds := dst.Bounds() + interp := draw.Interpolator(draw.CatmullRom) + if useCheapScaleInterpolator(bounds.Dx(), bounds.Dy(), srcW, srcH) { + interp = draw.ApproxBiLinear + } + interp.Scale(dst, bounds, src, src.Bounds(), draw.Src, nil) +} + +// isOpaqueImage 逐像素确认图像不含透明像素。dst 已知是 *image.RGBA,用 +// RGBAAt 直取字段可以避免 At() 的接口分派与颜色模型换算(PNG 海报每次 +// 生成缩略图都要走一遍全图扫描)。 func isOpaqueImage(img *image.RGBA) bool { + if img == nil { + return false + } bounds := img.Bounds() for y := bounds.Min.Y; y < bounds.Max.Y; y++ { for x := bounds.Min.X; x < bounds.Max.X; x++ { - if _, _, _, a := img.At(x, y).RGBA(); a != 0xffff { + if img.RGBAAt(x, y).A != 0xff { return false } } @@ -189,19 +228,91 @@ func isOpaqueImage(img *image.RGBA) bool { return true } -// resizeCacheKey 生成缩放结果的缓存键,覆盖源文件身份(路径 + 大小 + -// 修改时间)与全部影响输出的参数,源文件被替换后不会命中陈旧缩略图。 -func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string { +// resizeSourceKey 生成源图身份键:只覆盖源文件身份(路径 + 大小 + mtime),不含 +// 目标尺寸。同一源图的所有尺寸档位共用这个前缀,才能互相列举与派生(小图直接 +// 从已缓存的大图缩小,而不是重新解码原图)。 +func (o imageResizeOptions) resizeSourceKey(sourceID string, stat os.FileInfo) string { h := sha256.New() - _, _ = fmt.Fprintf(h, "v1|%s|%dx%d|q%d", sourceID, o.MaxWidth, o.MaxHeight, o.encodingQuality()) + _, _ = fmt.Fprintf(h, "v2|%s", sourceID) if stat != nil { _, _ = fmt.Fprintf(h, "|%d|%d", stat.Size(), stat.ModTime().UnixNano()) } return hex.EncodeToString(h.Sum(nil)) } -func (p *ImageProxy) resizeCachePath(key string) string { - return filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".img") +// resizeCacheKey 是「源图身份 + 档位」的稳定标识,用于 ETag 等需要区分档位的 +// 场合(磁盘路径只用源图身份 + 文件名后缀)。 +func (o imageResizeOptions) resizeCacheKey(sourceID string, stat os.FileInfo) string { + return o.resizeSourceKey(sourceID, stat) + "." + o.resizeVariantSuffix() +} + +func (p *ImageProxy) resizeCachePath(sourceKey string, o imageResizeOptions) string { + return filepath.Join(p.cacheDir, imageResizeCacheSubdir, sourceKey+"."+o.resizeVariantSuffix()+".img") +} + +// resizeVariantSuffix 把目标尺寸与质量写进缩放结果的文件名。这样同一源图的 +// 各档尺寸可以被列举出来:请求小图时若已有更大的档位缓存,直接从它缩小即可, +// 不必再解码多兆字节的原图(解码 480×600 约 1MB RGBA,解码原图可达数十 MB)。 +func (o imageResizeOptions) resizeVariantSuffix() string { + return strconv.Itoa(o.MaxWidth) + "x" + strconv.Itoa(o.MaxHeight) + "q" + strconv.Itoa(o.encodingQuality()) +} + +// parseResizeVariantSuffix 解析 ".xq.img" 文件名中的尺寸。 +func parseResizeVariantSuffix(name, sourceKey string) (width, height int, ok bool) { + prefix := sourceKey + "." + if !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".img") { + return 0, 0, false + } + spec := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".img") + dimensions, quality, found := strings.Cut(spec, "q") + if !found { + return 0, 0, false + } + w, h, found := strings.Cut(dimensions, "x") + if !found { + return 0, 0, false + } + width, err := strconv.Atoi(w) + if err != nil || width < 0 { + return 0, 0, false + } + height, err = strconv.Atoi(h) + if err != nil || height < 0 { + return 0, 0, false + } + if _, err := strconv.Atoi(quality); err != nil { + return 0, 0, false + } + return width, height, true +} + +// largerCachedVariant 找出同一源图已缓存的、能覆盖目标尺寸的最小档位(宽高 +// 都不小于目标)。返回空串表示没有可用的档位,调用方回退到解码原图。 +func (p *ImageProxy) largerCachedVariant(key string, o imageResizeOptions) string { + pattern := filepath.Join(p.cacheDir, imageResizeCacheSubdir, key+".*.img") + matches, err := filepath.Glob(pattern) + if err != nil { + return "" + } + best := "" + bestPixels := 0 + for _, path := range matches { + width, height, ok := parseResizeVariantSuffix(filepath.Base(path), key) + if !ok { + continue + } + if width == o.MaxWidth && height == o.MaxHeight { + continue // 精确档位,本该在上面就命中 + } + if width < o.MaxWidth || height < o.MaxHeight { + continue + } + pixels := width * height + if best == "" || pixels < bestPixels { + best, bestPixels = path, pixels + } + } + return best } // acquireResizeSlot bounds CPU-heavy decode/resize work. Returning false means @@ -265,7 +376,7 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request // 缓存命中必须发生在读原图和解码之前。否则电视端每次刷新海报墙都会 // 把已经是缩略图缓存的原图重新解码、缩放一遍,造成明显的 CPU 抖动。 key := o.resizeCacheKey(srcPath, stat) - cachePath := p.resizeCachePath(key) + cachePath := p.resizeCachePath(o.resizeSourceKey(srcPath, stat), o) if serveCachedImageFile(w, r, key, cachePath) { return true } @@ -286,7 +397,15 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request return true } - data, err := os.ReadFile(srcPath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path. + // 优先从同一源图已缓存的大尺寸档位缩小:小图(如 160px 模糊占位图) + // 往往能直接由已缓存的卡片图派生,代价从“解码多兆字节原图”降为 + // “解码几百 KB 的档位文件”。 + sourcePath := srcPath + if derived := p.largerCachedVariant(o.resizeSourceKey(srcPath, stat), o); derived != "" { + sourcePath = derived + } + + data, err := os.ReadFile(sourcePath) // #nosec G304 -- srcPath comes from an allowed local path or a SHA-derived cache path. if err != nil { return false } @@ -306,6 +425,48 @@ func (p *ImageProxy) serveResizedFromFile(w http.ResponseWriter, r *http.Request return true } +// ensureResizeCache 在请求路径之外预先生成某档缩略图(刮削预取用)。已存在 +// 时直接返回,无需调用方再次解码。 +func (p *ImageProxy) ensureResizeCache(ctx context.Context, srcPath string, o imageResizeOptions) error { + if p == nil || !o.active() { + return nil + } + stat, err := os.Stat(srcPath) + if err != nil || stat.IsDir() || stat.Size() <= 0 { + return errors.New("image source unavailable") + } + sourceKey := o.resizeSourceKey(srcPath, stat) + cachePath := p.resizeCachePath(sourceKey, o) + if _, err := os.Stat(cachePath); err == nil { + return nil + } + release, ok := p.acquireResizeSlotFor(ctx, stat.Size()) + if !ok { + return errors.New("image resize slot unavailable") + } + defer release() + if _, err := os.Stat(cachePath); err == nil { + return nil + } + sourcePath := srcPath + if derived := p.largerCachedVariant(sourceKey, o); derived != "" { + sourcePath = derived + } + data, err := os.ReadFile(sourcePath) // #nosec G304 -- srcPath is an allowed local path or a SHA-derived cache path. + if err != nil { + return err + } + out, _, unchanged, err := resizeImageData(data, o) + if err != nil { + return err + } + if unchanged { + return nil // 源图已在目标尺寸内,客户端会直接使用原文件。 + } + p.writeResizeCache(cachePath, out) + return nil +} + // compactImageSkipBytes 是“直接出原图”的体积上限。超过它的原图(多兆字节 // 的剧照、未压缩 sidecar)仍然走缩放,避免把大文件直接塞给电视端。 // ponytail: 200KB 覆盖这台机器上的典型海报(平均约 100KB);更大的图仍尝试缩放。 diff --git a/internal/service/image_resize_budget_test.go b/internal/service/image_resize_budget_test.go new file mode 100644 index 0000000..ec77ea8 --- /dev/null +++ b/internal/service/image_resize_budget_test.go @@ -0,0 +1,113 @@ +package service + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/jpeg" + "testing" +) + +// encodeTestJPEG 生成一张便于压缩的测试用 JPEG(比 PNG 更适合构造大尺寸样本)。 +func encodeTestJPEG(t *testing.T, 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, color.RGBA{R: uint8(x % 32 * 8), G: uint8(y % 32 * 8), B: 150, A: 255}) + } + } + var buf bytes.Buffer + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 70}); err != nil { + t.Fatalf("encode test jpeg: %v", err) + } + return buf.Bytes() +} + +// TestResizeImageDataRejectsOverPixelBudget 锁定解码像素预算:超过上限的 +// 原图必须放弃缩放(回退原图直出),而不是在小内存主机上尝试解码。 +func TestResizeImageDataRejectsOverPixelBudget(t *testing.T) { + // 12_000_000 像素预算之上:3000×5000 = 1500 万。 + data := encodeTestJPEG(t, 3000, 5000) + + if _, _, _, err := resizeImageData(data, imageResizeOptions{MaxWidth: 400}); !errors.Is(err, errImageResizeTooLarge) { + t.Fatalf("expected errImageResizeTooLarge, got %v", err) + } +} + +// TestResizeImageDataAcceptsRealisticPoster 常见高清海报(2892×4096,约 +// 1180 万像素)必须仍在预算之内,否则真实海报会退化成直出多兆字节原图。 +func TestResizeImageDataAcceptsRealisticPoster(t *testing.T) { + data := encodeTestJPEG(t, 2892, 4096) + + out, ctype, unchanged, err := resizeImageData(data, imageResizeOptions{MaxWidth: 480, MaxHeight: 600}) + if err != nil { + t.Fatalf("resizeImageData failed: %v", err) + } + if unchanged { + t.Fatal("expected a resized result, got the original bytes") + } + if ctype != "image/jpeg" { + t.Fatalf("content type = %q, want image/jpeg", ctype) + } + cfg, _, err := image.DecodeConfig(bytes.NewReader(out)) + if err != nil { + t.Fatalf("decode resized output: %v", err) + } + if cfg.Width != 424 || cfg.Height != 600 { + t.Fatalf("resized to %dx%d, want 424x600", cfg.Width, cfg.Height) + } +} + +// TestUseCheapScaleInterpolator 大比例缩小走低成本插值,轻微缩小仍保持高质量。 +func TestUseCheapScaleInterpolator(t *testing.T) { + cases := []struct { + name string + dstW, dstH, srcW, srcH int + want bool + }{ + {"poster to card", 480, 600, 2892, 4096, true}, + {"hero to strip", 480, 320, 1920, 1080, true}, + {"exactly half is cheap", 960, 540, 1920, 1080, true}, + {"slight shrink", 900, 540, 1000, 600, false}, + {"upscale", 1200, 800, 600, 400, false}, + {"zero source", 100, 100, 0, 0, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := useCheapScaleInterpolator(tc.dstW, tc.dstH, tc.srcW, tc.srcH); got != tc.want { + t.Fatalf("useCheapScaleInterpolator(%d,%d,%d,%d) = %v, want %v", + tc.dstW, tc.dstH, tc.srcW, tc.srcH, got, tc.want) + } + }) + } +} + +// TestIsOpaqueImageDetectsAlpha 透明度检测:全不透明才允许转 JPEG。 +func TestIsOpaqueImageDetectsAlpha(t *testing.T) { + opaque := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + opaque.Set(x, y, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + } + } + if !isOpaqueImage(opaque) { + t.Fatal("expected fully opaque image to be reported opaque") + } + + withAlpha := image.NewRGBA(image.Rect(0, 0, 4, 4)) + for y := 0; y < 4; y++ { + for x := 0; x < 4; x++ { + withAlpha.Set(x, y, color.RGBA{R: 10, G: 20, B: 30, A: 255}) + } + } + withAlpha.Set(2, 2, color.RGBA{R: 10, G: 20, B: 30, A: 128}) + if isOpaqueImage(withAlpha) { + t.Fatal("expected image with a translucent pixel to be reported non-opaque") + } + + if isOpaqueImage(nil) { + t.Fatal("expected nil image to be reported non-opaque") + } +} diff --git a/internal/service/image_resize_derive_test.go b/internal/service/image_resize_derive_test.go new file mode 100644 index 0000000..12421dd --- /dev/null +++ b/internal/service/image_resize_derive_test.go @@ -0,0 +1,184 @@ +package service + +import ( + "bytes" + "image" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "go.uber.org/zap" +) + +func TestParseResizeVariantSuffixRoundTrip(t *testing.T) { + opts := imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80} + name := "abc123." + opts.resizeVariantSuffix() + ".img" + + width, height, ok := parseResizeVariantSuffix(name, "abc123") + if !ok { + t.Fatalf("expected %q to parse", name) + } + if width != 480 || height != 600 { + t.Fatalf("parsed %dx%d, want 480x600", width, height) + } + + for _, bad := range []string{"abc123.img", "abc124.480x600q80.img", "abc123.480x600.img", "abc123.axbq80.img"} { + if _, _, ok := parseResizeVariantSuffix(bad, "abc123"); ok { + t.Fatalf("expected %q to be rejected", bad) + } + } +} + +func TestLargerCachedVariantPicksSmallestCoveringVariant(t *testing.T) { + proxy := &ImageProxy{cacheDir: filepath.Join(t.TempDir(), "cache"), log: zap.NewNop()} + dir := filepath.Join(proxy.cacheDir, imageResizeCacheSubdir) + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatal(err) + } + + key := "deadbeef" + writeVariant := func(o imageResizeOptions) string { + path := proxy.resizeCachePath(key, o) + if err := os.WriteFile(path, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + return path + } + // 更小的档位不能用于派生(会放大),更大的档位里要挑最小的那个。 + writeVariant(imageResizeOptions{MaxWidth: 96, Quality: 60}) + large := writeVariant(imageResizeOptions{MaxWidth: 1920, MaxHeight: 1080, Quality: 80}) + card := writeVariant(imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80}) + + got := proxy.largerCachedVariant(key, imageResizeOptions{MaxWidth: 160, Quality: 60}) + if got != card { + t.Fatalf("largerCachedVariant = %q, want the card variant %q", got, card) + } + + // 目标尺寸已与大档位相同(甚至更大)时没有可用来源。 + if got := proxy.largerCachedVariant(key, imageResizeOptions{MaxWidth: 1920, MaxHeight: 1080, Quality: 80}); got != "" { + t.Fatalf("expected no derivable source when the target equals the only covering variant, got %q", got) + } + if got := proxy.largerCachedVariant(key, imageResizeOptions{MaxWidth: 2400, Quality: 80}); got != "" { + t.Fatalf("expected no derivable source for oversize targets, got %q", got) + } + if large == "" { + t.Fatal("large variant should have been written") + } +} + +// TestServeResizedDerivesSmallVariantFromCachedCard 小尺寸档位必须能从已缓存 +// 的大尺寸档位派生,而不是每次都重新解码原图。 +// +// 验证方式:生成卡片档位后,把原图内容替换成同长度、同 mtime 的非图片数据 +// (缓存键不变,但原图已无法解码)。若小图请求仍能返回正确的缩略图,就说明它 +// 是从缓存档位派生的;否则只能退回原图直出(此处会失败,因为原图已不是图片)。 +func TestServeResizedDerivesSmallVariantFromCachedCard(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "poster.png") + original := encodeTestPNG(t, 1200, 1800, 255) + if err := os.WriteFile(source, original, 0o644); err != nil { + t.Fatal(err) + } + // 固定 mtime 并读回平台量化后的值,保证替换内容后缓存键不变。 + fixed := time.Now().Add(-time.Hour).Truncate(time.Second) + if err := os.Chtimes(source, fixed, fixed); err != nil { + t.Fatal(err) + } + srcStat, err := os.Stat(source) + if err != nil { + t.Fatal(err) + } + + proxy := &ImageProxy{cacheDir: filepath.Join(dir, "cache"), log: zap.NewNop()} + + card := imageResizeOptions{MaxWidth: 480, MaxHeight: 600, Quality: 80} + cardRec := httptest.NewRecorder() + if !proxy.serveResizedFromFile(cardRec, httptest.NewRequest("GET", "/x?maxWidth=480", nil), source, card) { + t.Fatal("expected the card variant request to be handled") + } + cardCfg, _, err := image.DecodeConfig(bytes.NewReader(cardRec.Body.Bytes())) + if err != nil { + t.Fatalf("decode card variant: %v", err) + } + if cardCfg.Width != 400 || cardCfg.Height != 600 { + t.Fatalf("card variant = %dx%d, want 400x600", cardCfg.Width, cardCfg.Height) + } + + // 把原图换成同样长度的非图片数据,并恢复完全相同的 mtime,保持缓存键不变。 + garbage := bytes.Repeat([]byte{0x11}, len(original)) + if err := os.WriteFile(source, garbage, 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(source, srcStat.ModTime(), srcStat.ModTime()); err != nil { + t.Fatal(err) + } + + tiny := imageResizeOptions{MaxWidth: 160, Quality: 60} + tinyRec := httptest.NewRecorder() + if !proxy.serveResizedFromFile(tinyRec, httptest.NewRequest("GET", "/x?maxWidth=160", nil), source, tiny) { + t.Fatal("expected the tiny variant request to be handled") + } + tinyCfg, _, err := image.DecodeConfig(bytes.NewReader(tinyRec.Body.Bytes())) + if err != nil { + t.Fatalf("decode tiny variant: %v (the request fell back to the placeholder instead of deriving from the cached card)", err) + } + if tinyCfg.Width != 160 || tinyCfg.Height != 240 { + t.Fatalf("tiny variant = %dx%d, want 160x240", tinyCfg.Width, tinyCfg.Height) + } +} + +// TestPrefetchCardVariantWarmsResizeCache 刮削预取要顺带生成卡片档位, +// 首个海报墙请求即可命中缩放缓存。 +func TestPrefetchCardVariantWarmsResizeCache(t *testing.T) { + payload := encodeTestPNG(t, 1200, 1800, 255) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(payload) + })) + defer upstream.Close() + + proxy := newStreamImageProxy(t, upstream.URL) + raw := upstream.URL + "/poster.png" + + if err := proxy.PrefetchCardVariant(t.Context(), raw); err != nil { + t.Fatalf("PrefetchCardVariant failed: %v", err) + } + + _, cachePath, _ := proxy.remoteImageCachePathsForValidated(raw) + stat, err := os.Stat(cachePath) + if err != nil { + t.Fatalf("expected the original to be cached: %v", err) + } + variantPath := proxy.resizeCachePath(prefetchCardResizeOptions.resizeSourceKey(cachePath, stat), prefetchCardResizeOptions) + if _, err := os.Stat(variantPath); err != nil { + t.Fatalf("expected the card variant to be pre-generated at %s: %v", variantPath, err) + } + + // 预取失败时仍要返回错误(刮削据此保留旧图)。 + bad := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + })) + defer bad.Close() + failing := newStreamImageProxy(t, bad.URL) + if err := failing.PrefetchCardVariant(t.Context(), bad.URL+"/poster.png"); err == nil { + t.Fatal("expected an error for an unreachable candidate image") + } +} + +// TestPrefetchCardVariantSurvivesUnresizableImage 上游返回的图无法缩放时, +// 预取仍视为成功(图片可达即可),不能因此让刮削回退到旧图。 +func TestPrefetchCardVariantSurvivesUnresizableImage(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "image/jpeg") + _, _ = w.Write(testJPEG) + })) + defer upstream.Close() + + proxy := newStreamImageProxy(t, upstream.URL) + if err := proxy.PrefetchCardVariant(t.Context(), upstream.URL+"/cover.jpg"); err != nil { + t.Fatalf("PrefetchCardVariant failed: %v", err) + } +} diff --git a/internal/service/runtime_settings.go b/internal/service/runtime_settings.go index 31696cd..59b0efb 100644 --- a/internal/service/runtime_settings.go +++ b/internal/service/runtime_settings.go @@ -119,6 +119,20 @@ func ApplyRuntimeSetting(cfg *config.Config, key, value string) { } cfg.Cache.ImagesMaxSizeMB = n } + case "cache.images_originals_max_size_mb": + if n, err := strconv.Atoi(value); err == nil { + if n < 0 { + n = 0 + } + 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 9974f7a..050531e 100644 --- a/internal/service/scheduler.go +++ b/internal/service/scheduler.go @@ -39,7 +39,7 @@ type SchedulerService struct { cacheDir string now func() time.Time - imagesMaxSizeMBProvider func() int + imagesPolicyProvider func() ImageCachePolicy mu sync.Mutex stopCh chan struct{} @@ -59,15 +59,22 @@ func (s *SchedulerService) SetOrganizePipeline(pipeline *OrganizePipelineService s.organizePipeline = pipeline } -func (s *SchedulerService) SetImagesMaxSizeMBProvider(fn func() int) { - s.imagesMaxSizeMBProvider = fn +// ImageCachePolicy 是一次图片缓存清理要用的策略(全部为 0 表示不做任何清理)。 +type ImageCachePolicy struct { + TotalBytes int64 + OriginalsBytes int64 + OriginalsAge time.Duration } -func (s *SchedulerService) imagesMaxSizeMB() int { - if s.imagesMaxSizeMBProvider != nil { - return s.imagesMaxSizeMBProvider() +func (s *SchedulerService) SetImageCachePolicyProvider(fn func() ImageCachePolicy) { + s.imagesPolicyProvider = fn +} + +func (s *SchedulerService) imageCachePolicy() ImageCachePolicy { + if s.imagesPolicyProvider != nil { + return s.imagesPolicyProvider() } - return 0 + return ImageCachePolicy{} } // scheduledJob is one recurring task. diff --git a/internal/service/scheduler_local_jobs.go b/internal/service/scheduler_local_jobs.go index 5e02da6..107f476 100644 --- a/internal/service/scheduler_local_jobs.go +++ b/internal/service/scheduler_local_jobs.go @@ -178,18 +178,19 @@ func (s *SchedulerService) jobCleanTranscodeCache(ctx context.Context) error { return walkAndPrune(s.cacheDir+"/hls", cutoff) } -// jobCleanImageCache prunes image proxy cache files when disk usage exceeds the configured limit. +// jobCleanImageCache prunes the image proxy cache: 原图按保留时长与独立配额 +// 优先淘汰,总量超限时再淘汰派生成品。 func (s *SchedulerService) jobCleanImageCache(ctx context.Context) error { if s.cacheDir == "" { return nil } - maxMB := s.imagesMaxSizeMB() - if maxMB <= 0 { + policy := s.imageCachePolicy() + if policy.TotalBytes <= 0 && policy.OriginalsBytes <= 0 && policy.OriginalsAge <= 0 { return nil } imagesDir := filepath.Join(s.cacheDir, "images") - maxSizeBytes := int64(maxMB) * 1024 * 1024 - res, err := PruneImageCache(imagesDir, maxSizeBytes) + pools := ImageCachePools(imagesDir, policy.OriginalsBytes, policy.OriginalsAge) + res, err := PruneImageCachePools(pools, policy.TotalBytes) if err != nil { if s.log != nil { s.log.Warn("scheduled image cache cleanup failed", zap.Error(err)) diff --git a/internal/service/scraper_artwork.go b/internal/service/scraper_artwork.go index ce3fd04..a7eb0d4 100644 --- a/internal/service/scraper_artwork.go +++ b/internal/service/scraper_artwork.go @@ -29,7 +29,7 @@ func (s *ScraperService) prepareScrapedArtworkURL(ctx context.Context, mediaID, timeout = 8 * time.Second } fetchCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), timeout) - err := s.images.PrefetchRemote(fetchCtx, candidate) + err := s.images.PrefetchCardVariant(fetchCtx, candidate) cancel() if err != nil { if originalSource != "" { diff --git a/internal/service/service_builder.go b/internal/service/service_builder.go index 5e0c0f3..f07d6a3 100644 --- a/internal/service/service_builder.go +++ b/internal/service/service_builder.go @@ -4,6 +4,7 @@ import ( "context" "errors" "strings" + "time" "go.uber.org/zap" @@ -166,11 +167,20 @@ func (b *serviceContainerBuilder) initAccessAndStorageServices() { ) b.c.Scheduler.SetTaskTracker(b.c.Tasks) b.c.Scheduler.SetOrganizePipeline(b.c.OrganizePipeline) - b.c.Scheduler.SetImagesMaxSizeMBProvider(func() int { + b.c.Scheduler.SetImageCachePolicyProvider(func() ImageCachePolicy { if b.cfg == nil { - return 0 + return ImageCachePolicy{} } - return b.cfg.Cache.ImagesMaxSizeMB + policy := ImageCachePolicy{ + TotalBytes: int64(b.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024, + } + 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 }) } diff --git a/web/public/artwork-cache-sw.js b/web/public/artwork-cache-sw.js index 92d4e0e..cb313eb 100644 --- a/web/public/artwork-cache-sw.js +++ b/web/public/artwork-cache-sw.js @@ -1,8 +1,22 @@ const ARTWORK_CACHE_PREFIX = 'mebox-artwork-' -const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v2` +const ARTWORK_CACHE = `${ARTWORK_CACHE_PREFIX}v3` const MIN_CACHEABLE_ARTWORK_BYTES = 128 const STRIP_QUERY_KEYS = ['token', 'profile_id', 'profile_pin_token'] +// 浏览器侧作品缓存的容量上限。 +// +// 没有上限时 Cache Storage 会一直增长,浏览器在存储压力下会整体清空该 +// origin 的缓存,失效时机完全不可控。条数上限每次写入都检查(只是读一遍 +// 键,开销小);字节上限最多每两分钟统计一次,因为需要逐个读取已存响应的 +// Content-Length。两者都淘汰到上限的 80%,避免刚清完又立刻触发。 +const MAX_CACHE_ENTRIES = 500 +const MAX_CACHE_BYTES = 64 * 1024 * 1024 +const TRIM_TARGET_RATIO = 0.8 +const BYTE_TRIM_INTERVAL_MS = 2 * 60 * 1000 + +let lastByteTrimAt = 0 +let trimInFlight = null + function isArtworkRequest(url) { if (url.origin !== self.location.origin) return false if (url.pathname === '/api/img') return true @@ -54,10 +68,69 @@ async function cacheArtwork(request) { if (cacheResponse) { await cache.put(cacheKey, cacheResponse) await deleteOldArtworkVariants(cache, cacheKey) + await trimArtworkCache(cache) } return response } +// trimArtworkCache 把缓存裁剪回容量上限之内,按写入顺序淘汰(最旧的先删)。 +// 同一时刻只允许一次裁剪在跑。 +async function trimArtworkCache(cache) { + if (trimInFlight) return trimInFlight + trimInFlight = runArtworkTrim(cache).catch(() => undefined) + try { + await trimInFlight + } finally { + trimInFlight = null + } +} + +async function runArtworkTrim(cache) { + const keys = await cache.keys() + if (keys.length > MAX_CACHE_ENTRIES) { + const keep = Math.floor(MAX_CACHE_ENTRIES * TRIM_TARGET_RATIO) + await deleteOldestArtwork(cache, keys, keys.length - keep) + return + } + + const now = Date.now() + if (now - lastByteTrimAt < BYTE_TRIM_INTERVAL_MS) return + lastByteTrimAt = now + + const sizes = [] + let total = 0 + for (const key of keys) { + const response = await cache.match(key) + const size = cacheableResponseSize(response) + sizes.push(size) + total += size + } + if (total <= MAX_CACHE_BYTES) return + + const target = Math.floor(MAX_CACHE_BYTES * TRIM_TARGET_RATIO) + let remaining = total + const evicted = [] + for (let i = 0; i < keys.length && remaining > target; i += 1) { + remaining -= sizes[i] + evicted.push(keys[i]) + } + await Promise.all(evicted.map((key) => cache.delete(key))) +} + +// cacheableResponseSize 用 Content-Length 估算体积。MeBox 的图片响应由 +// ServeContent 生成,始终带该头;缺失时按 0 计(只是少算,不会误删)。 +function cacheableResponseSize(response) { + if (!response) return 0 + const size = Number(response.headers.get('Content-Length') || '0') + return Number.isFinite(size) && size > 0 ? size : 0 +} + +// deleteOldestArtwork 按 cache.keys() 的顺序(写入顺序)删除最旧的若干条。 +async function deleteOldestArtwork(cache, keys, count) { + const victims = keys.slice(0, Math.max(0, count)) + await Promise.all(victims.map((key) => cache.delete(key))) +} + async function cloneCacheableArtworkResponse(response) { if (!response.ok) return null const contentType = response.headers.get('Content-Type') || '' diff --git a/web/src/pages/settingsGroupGeneral.ts b/web/src/pages/settingsGroupGeneral.ts index 07ebccc..a9e98d2 100644 --- a/web/src/pages/settingsGroupGeneral.ts +++ b/web/src/pages/settingsGroupGeneral.ts @@ -111,10 +111,26 @@ export const generalSettingsGroup: SettingGroup = { key: 'cache.images_max_size_mb', label: '图片缓存上限 (MB)', type: 'number', - hint: '海报、剧照等图片代理缓存目录 (cache/images) 的最大占用空间。超过上限时自动按修改时间清理最旧的文件;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB', + hint: '海报、剧照等图片代理缓存目录 (cache/images) 的总占用上限。超过上限时先淘汰原图、再按修改时间清理最旧的缩略图;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB', defaultValue: '2000', placeholder: '2000', }, + { + key: 'cache.images_originals_max_size_mb', + label: '原图缓存上限 (MB)', + type: 'number', + hint: '原图只是生成缩略图的原料,丢了可以重新回源,因此单独给一个小配额(默认 256MB)。总量超限时原图也优先被淘汰,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制', + 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',