// Package service — generic on-disk cleanup helper used by the // scheduler. Public so handlers can call it for "purge transcode cache // now" buttons. package service import ( "encoding/hex" "os" "path/filepath" "sort" "strings" "time" ) // walkAndPrune recursively deletes every file under root whose mtime is // older than cutoff. Empty directories left behind are removed too. // Best-effort: per-file errors are ignored so a single permission denial // doesn't abort the cleanup. func walkAndPrune(root string, cutoff time.Time) error { if root == "" { return nil } if _, err := os.Stat(root); err != nil { return nil // nothing to clean } dirs := []string{} _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } if info.IsDir() { if path != root { dirs = append(dirs, path) } return nil } if info.ModTime().Before(cutoff) { _ = os.Remove(path) // #nosec G122 -- cache pruning is best-effort under the configured cache root. } return nil }) // Remove emptied directories from deepest to shallowest. for i := len(dirs) - 1; i >= 0; i-- { _ = os.Remove(dirs[i]) } return nil } // PruneImageCacheResult holds stats from an image cache prune operation. type PruneImageCacheResult struct { TotalFilesBefore int TotalBytesBefore int64 DeletedFiles int FreedBytes int64 RemainingBytes int64 } // imageCacheFileEntry 是池内一个可被淘汰的缓存文件。 type imageCacheFileEntry struct { path string pool int size int64 modTime time.Time } // ImageCacheScope 是池内的一处文件范围。Recursive=false 时只处理该目录下的 // 直属文件(历史版本的图片缓存是平铺在 images/ 下的,升级后仍要能清理掉)。 type ImageCacheScope struct { Root string Recursive bool // DropUnknownResizeVariants 只用于派生缩略图目录:直接删除文件名不符合当前 // 命名(<源图键>.<宽>x<高>q<质量>.img)的历史缩放缓存。它们在新的查找路径下 // 永远不会被命中,留着只会占用总量配额、把有用缓存挤出去。 DropUnknownResizeVariants bool } // ImageCachePool 描述一个图片缓存池:范围 + 自身配额。 // // 分池的意义在于两类缓存的“可再生成本”完全不同:原图只是生成缩略图的原料, // 丢了可以重新回源;派生缩略图是客户端热路径真正读取的成品,重新生成代价高。 // 因此原图配小配额,总量超限时也优先淘汰原图。 // // 淘汰只看占用(配额头寸),不按文件年龄删除:缩略图生成成本高,只要磁盘还 // 放得下就该留着,让再次浏览直接命中缓存。 type ImageCachePool struct { Name string Scopes []ImageCacheScope MaxBytes int64 // 0 = 不单独限制 } // 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. 池自身超过 MaxBytes 时按 mtime 淘汰到 80%(留水位,避免连续写入即触发); // 2. 全部池合计超过 totalBytes 时,仍按“先原图、后派生”的顺序淘汰最旧文件, // 使总量上限始终是硬保证,同时让客户端热路径的缩略图活得更久。 // // 不按文件年龄删除:过期即删会让一张几天前看过的海报重新解码(2 核机器上 // 每张约 0.2 秒),代价远高于占着那几十 KB 磁盘。 // // 传入的池顺序即总量超限时的淘汰优先级(排在前面的先被淘汰)。 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 // 失败标记不计入容量:只清掉对应缓存文件已经不存在的孤儿标记(它们只是 // 观测/重试用的噪音,留着没有任何作用)。 for _, marker := range failMarkers { base := strings.TrimSuffix(marker.path, ".fail") if _, err := os.Stat(base); err == nil { continue } _ = os.Remove(marker.path) } 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 { 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 } } // 2. 总量硬上限:跨池按淘汰优先级(池顺序)再按 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 { return result, nil } if _, err := os.Stat(imagesDir); err != nil { return result, nil } pool := ImageCachePool{ Name: "images", Scopes: []ImageCacheScope{{Root: imagesDir, Recursive: true}}, MaxBytes: maxSizeBytes, } return PruneImageCachePools([]ImageCachePool{pool}, maxSizeBytes) } // ImageCachePools 按配置组装图片缓存的两个清理池。 // // 第一个池是原图:历史版本平铺在 images/ 下的旧缓存也归入此池,升级后会被 // 逐步淘汰;第二个池是派生成品(本地缩放结果 + 挂载 Emby 按尺寸返回的成品)。 // 池顺序即总量超限时的淘汰优先级。 func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool { originals := ImageCachePool{ Name: "originals", MaxBytes: originalsMaxBytes, 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} } // CacheCleanupResult 是全缓存容量淘汰的统计。 type CacheCleanupResult struct { TotalFilesBefore int TotalBytesBefore int64 DeletedFiles int FreedBytes int64 RemainingBytes int64 // FreedByPool 记录每个池释放的字节数,方便日志定位到底是哪类缓存占满了。 FreedByPool map[string]int64 } // cacheEvictionEntry 是全局容量淘汰里的一个候选文件。 type cacheEvictionEntry struct { path string pool string size int64 modTime time.Time } // cacheEvictionPoolOrder 是全局容量清理的固定优先级。 // 顺序即“先清谁”:超限时前面的池会被整池清空,才会轮到后面的池。 // reader-audio 不在其中:它由自身固定配额(当前 1GiB,见 reader 包的 // audioMediaMaxBytes)在写入时独立淘汰。 var cacheEvictionPoolOrder = []string{ "hls", "images", "library-covers", } // cacheCleanupKeepPercent 是触发清理后回落到的水位(占上限的百分比)。 // 留出余量,避免刚清完又被下一次写入立刻顶到上限。 const cacheCleanupKeepPercent = 80 // tempCacheFile 判断文件名是否是缓存写入中的临时产物。这些文件正在被并发 // 写入,容量统计和清理都应跳过。 func tempCacheFile(name string) bool { if tempImageCacheFile(name) { return true } if strings.HasSuffix(name, ".part") { return true } // 各缓存目录的原子写入临时文件前缀:.content- / .cover- / .audio- / .js-cache-。 return strings.HasPrefix(name, ".content-") || strings.HasPrefix(name, ".cover-") || strings.HasPrefix(name, ".audio-") || strings.HasPrefix(name, ".js-cache-") } // collectCachePoolFiles 递归收集一个普通缓存池里的候选文件,按 mtime 从旧到新。 func collectCachePoolFiles(root, pool string) []cacheEvictionEntry { if strings.TrimSpace(root) == "" { return nil } var out []cacheEvictionEntry _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info == nil || info.IsDir() { return nil } if !info.Mode().IsRegular() { return nil } name := info.Name() if tempCacheFile(name) || strings.HasSuffix(name, ".fail") { return nil } out = append(out, cacheEvictionEntry{ path: path, pool: pool, size: info.Size(), modTime: info.ModTime(), }) return nil }) sort.Slice(out, func(i, j int) bool { return out[i].modTime.Before(out[j].modTime) }) return out } // collectImageCacheEntries 收集图片缓存候选文件。返回顺序沿用图片池的优先级: // 先原图、后派生成品,池内按 mtime 从旧到新。 func collectImageCacheEntries(imagesDir string) []cacheEvictionEntry { if strings.TrimSpace(imagesDir) == "" { return nil } if _, err := os.Stat(imagesDir); err != nil { return nil } pools := ImageCachePools(imagesDir, 0) var entries []imageCacheFileEntry var failMarkers []imageCacheFileEntry for i, pool := range pools { collectImageCachePoolFiles(pool, i, &entries, &failMarkers) } ordered := evictionOrder(entries, map[string]bool{}) out := make([]cacheEvictionEntry, 0, len(ordered)) for _, e := range ordered { out = append(out, cacheEvictionEntry{ path: e.path, pool: "images", size: e.size, modTime: e.modTime, }) } return out } // removeEmptyDirsUnder 删除 root 下淘汰后留下的空子目录(不含 root 本身)。 func removeEmptyDirsUnder(root string) { if strings.TrimSpace(root) == "" { return } if _, err := os.Stat(root); err != nil { return } dirs := []string{} _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { if err != nil || info == nil { return nil } if info.IsDir() && path != root { dirs = append(dirs, path) } return nil }) for i := len(dirs) - 1; i >= 0; i-- { _ = os.Remove(dirs[i]) } } // PruneCache 按全局容量池清理磁盘缓存。 // // 总量口径是 hls、images、library-covers 三个池之和;reader-audio 及正文缓存、 // 书源文件缓存都不参与这里的总量计算——reader-audio 由自身固定配额(当前 // 1GiB)在写入时淘汰,正文缓存和书源文件缓存另有自己的 TTL / 配额。 // 超过 totalBytes 时严格按 hls → images → library-covers 的顺序淘汰最旧文件, // 直到回落到上限的 80%。 // originalsMaxBytes 是 images 原图池的独立子配额,在总量淘汰之后单独执行 // (即使总量没超也会把原图压回配额)。 func PruneCache(cacheDir string, totalBytes, originalsMaxBytes int64) (CacheCleanupResult, error) { result := CacheCleanupResult{FreedByPool: map[string]int64{}} if strings.TrimSpace(cacheDir) == "" { return result, nil } imagesDir := filepath.Join(cacheDir, "images") ordered := make([]cacheEvictionEntry, 0, 1024) for _, name := range cacheEvictionPoolOrder { if name == "images" { ordered = append(ordered, collectImageCacheEntries(imagesDir)...) continue } ordered = append(ordered, collectCachePoolFiles(filepath.Join(cacheDir, name), name)...) } result.TotalFilesBefore = len(ordered) for _, entry := range ordered { result.TotalBytesBefore += entry.size } result.RemainingBytes = result.TotalBytesBefore // 1. 全局总量:严格按池优先级淘汰,直到回落到水位。 if totalBytes > 0 && result.RemainingBytes > totalBytes { target := totalBytes * cacheCleanupKeepPercent / 100 for _, entry := range ordered { if result.RemainingBytes <= target { break } if err := os.Remove(entry.path); err != nil { continue } result.DeletedFiles++ result.FreedBytes += entry.size result.RemainingBytes -= entry.size result.FreedByPool[entry.pool] += entry.size _ = os.Remove(entry.path + ".fail") } } // 2. 图片原图子配额独立于总量:即使总量没超,也要把原图压回自己的配额, // 同时清掉旧命名变体和孤儿失败标记。 if _, err := os.Stat(imagesDir); err == nil { pools := ImageCachePools(imagesDir, originalsMaxBytes) if sub, err := PruneImageCachePools(pools, 0); err == nil { result.DeletedFiles += sub.DeletedFiles result.FreedBytes += sub.FreedBytes result.RemainingBytes -= sub.FreedBytes result.FreedByPool["images"] += sub.FreedBytes } } for _, name := range cacheEvictionPoolOrder { removeEmptyDirsUnder(filepath.Join(cacheDir, name)) } return result, nil }