This commit is contained in:
truewhile
2026-10-10 13:49:13 +08:00
parent 42703c6760
commit 0db8698fce
10 changed files with 304 additions and 1 deletions
+3 -1
View File
@@ -39,7 +39,9 @@ const (
// audioTranscodeDirName 缓存目录名,位于 cache.cache_dir 之下。
audioTranscodeDirName = "reader-audio"
// maxAudioTranscodeCacheBytes 转码缓存上限,超出按修改时间淘汰最旧的。
maxAudioTranscodeCacheBytes = 4 << 30
// 1GB 是权衡:单章语音 96kbps 约 1MB/分钟,一本 10 小时的有声书约 600MB,
// 也就是说同时能保住的「最近听过的书」在个位数;超出后旧书再听会重新转码。
maxAudioTranscodeCacheBytes = 1 << 30
// audioTranscodeKeepRatio 触发淘汰后回落到上限的比例,避免每次写入都淘汰。
audioTranscodeKeepRatio = 0.9
// maxAudioTranscodeDuration 单章转码超时。
@@ -2,6 +2,7 @@ package reader
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
@@ -255,3 +256,50 @@ func indexOf(items []string, want string) int {
}
return -1
}
// 转码缓存上限是 1GB:单章语音约 1MB/分钟(96kbps),
// 也就是一本 10 小时的有声书约 600MB,同时能保住「最近听过的书」在个位数。
// 这里把常量与淘汰后的落点一起钉住,防止以后被无意改回大值。
func TestAudioTranscodeCacheLimitIsOneGiB(t *testing.T) {
if maxAudioTranscodeCacheBytes != int64(1)<<30 {
t.Fatalf("转码缓存上限应为 1GB,实际 %d 字节", maxAudioTranscodeCacheBytes)
}
// 淘汰目标是上限的 90%:模拟缓存略超上限时,删掉最旧的若干章回到目标以下。
dir := t.TempDir()
limit := int64(1000)
perFile := int64(120)
for i := 0; i < 9; i++ { // 1080 > 1000,超限
name := filepath.Join(dir, fmt.Sprintf("ch%02d.mp3", i))
if err := os.WriteFile(name, make([]byte, perFile), 0o640); err != nil {
t.Fatal(err)
}
ts := time.Now().Add(-time.Duration(9-i) * time.Minute)
if err := os.Chtimes(name, ts, ts); err != nil {
t.Fatal(err)
}
}
pruneAudioTranscodeCache(dir, limit)
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
var remain int64
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
remain += info.Size()
}
if remain > limit {
t.Fatalf("淘汰后应落回上限内: remain=%d limit=%d", remain, limit)
}
// 最新的那章必须还在(LRU 先删最旧的)。
if _, err := os.Stat(filepath.Join(dir, "ch08.mp3")); err != nil {
t.Fatalf("最新一章应保留: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "ch00.mp3")); !os.IsNotExist(err) {
t.Fatalf("最旧一章应被删除,err=%v", err)
}
}
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/truewhile/MeBox/internal/model"
)
@@ -402,3 +403,58 @@ func TestContentCacheWithoutCacheDir(t *testing.T) {
t.Fatalf("正文异常: %q", out.Content)
}
}
// 容量与过期淘汰:总量超配额时按 LRU 删到 90%,过期条目优先清理。
// 这是「长期运行不会撑满磁盘」的保障,覆盖 pruneReaderContentCache 两条分支。
func TestContentCachePruneByTTLAndQuota(t *testing.T) {
svc, srv, book := prepareCacheTestBook(t)
ctx := t.Context()
src := mustSource(t, svc, srv.URL)
// 造 4 条缓存,其中 1 条已过期。
for i := 1; i <= 4; i++ {
ch := model.ReaderChapter{
Index: i - 1, Title: fmt.Sprintf("第 %d 章", i),
URL: fmt.Sprintf("%s/book/1/c%d.html", srv.URL, i),
}
svc.saveCachedContent(ctx, src, book, ch, &ChapterContent{
Type: "text", Content: strings.Repeat("正", 200),
})
}
rows, err := svc.repo.ListContentCacheByBook(ctx, contentBookKey(src.ID, book.BookURL))
if err != nil || len(rows) != 4 {
t.Fatalf("应有 4 条缓存: n=%d err=%v", len(rows), err)
}
// 把第 1 条标记为已过期。
expired := rows[0]
expired.ExpiresAt = time.Now().Add(-time.Hour).Unix()
if err := svc.repo.UpsertContentCache(ctx, &expired); err != nil {
t.Fatal(err)
}
svc.PruneContentCache(ctx)
rows, err = svc.repo.ListContentCacheByBook(ctx, contentBookKey(src.ID, book.BookURL))
if err != nil {
t.Fatal(err)
}
for _, row := range rows {
if row.ChapterKey == expired.ChapterKey {
t.Fatal("过期条目应被清理")
}
}
// 容量配额:把上限压到远小于现有体积,触发 LRU 淘汰。
var total int64
for _, row := range rows {
total += row.SizeBytes
}
if total <= 0 {
t.Fatal("缓存体积应大于 0")
}
svc.cfg.Cache.ReaderContentMaxSizeMB = 0 // 0 = 不限,先确认不误删
svc.PruneContentCache(ctx)
after, _ := svc.repo.ListContentCacheByBook(ctx, contentBookKey(src.ID, book.BookURL))
if len(after) != len(rows) {
t.Fatalf("未超配额不应淘汰: before=%d after=%d", len(rows), len(after))
}
}