mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-28 11:16:37 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47899284a5 |
@@ -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 节 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,在低配主机
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
@@ -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 ????????????????.<??x<??q<??>.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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 的映射,供旧式
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("<html><body>not an image</body></html>"))
|
||||
}))
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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 解析 "<sourceKey>.<w>x<h>q<q>.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);更大的图仍尝试缩放。
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Vendored
+74
-1
@@ -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') || ''
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user