diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 20b4fa2..d6dc115 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -45,7 +45,7 @@ func setDefaults(v *viper.Viper) { v.SetDefault("logging.max_backups", 10) v.SetDefault("cache.cache_dir", "./cache") - v.SetDefault("cache.images_max_size_mb", 2000) + v.SetDefault("cache.images_max_size_mb", 2048) v.SetDefault("cache.images_originals_max_size_mb", 256) v.SetDefault("cache.memory_max_size_mb", DefaultCacheMemoryMaxSizeMB) v.SetDefault("cache.redis_url", "") diff --git a/internal/config/types.go b/internal/config/types.go index fd0881d..252703c 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -120,8 +120,12 @@ type LoggingConfig struct { // CacheConfig 控制磁盘转写缓存和进程内热缓存。 type CacheConfig struct { - CacheDir string `mapstructure:"cache_dir"` - ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"` + CacheDir string `mapstructure:"cache_dir"` + // ImagesMaxSizeMB 是整个磁盘缓存的总容量上限(MB),不是 images 子目录 + // 的配额。参与统计的池为 hls、images、library-covers;超限时按该顺序淘汰 + // 最旧文件。reader-audio 由自身 1GB 配额在写入时独立淘汰,不参与总容量。 + // 0 表示不限制。 + ImagesMaxSizeMB int `mapstructure:"images_max_size_mb"` // ImagesOriginalsMaxSizeMB 限制 cache/images 下原图子配额(MB)。原图只是 // 生成缩略图的原料,丢了可以重新回源;0 表示不单独限制(只受总量约束)。 ImagesOriginalsMaxSizeMB int `mapstructure:"images_originals_max_size_mb"` diff --git a/internal/handler/admin_settings.go b/internal/handler/admin_settings.go index 6665665..f00f717 100644 --- a/internal/handler/admin_settings.go +++ b/internal/handler/admin_settings.go @@ -89,7 +89,7 @@ func updateSettingHandler(svc *service.Container) gin.HandlerFunc { } if req.Key == "cache.images_max_size_mb" || req.Key == "cache.images_originals_max_size_mb" { if svc.Scheduler != nil { - _ = svc.Scheduler.RunNowAsync(c.Request.Context(), "image_cache_cleanup") + _ = svc.Scheduler.RunNowAsync(c.Request.Context(), "cache_cleanup") } } if req.Key == "cache.memory_max_size_mb" && svc.Cache != nil { diff --git a/internal/handler/reader_media_test.go b/internal/handler/reader_media_test.go index 5c12d24..29b0ad1 100644 --- a/internal/handler/reader_media_test.go +++ b/internal/handler/reader_media_test.go @@ -1,6 +1,7 @@ package handler import ( + "bytes" "fmt" "net/http" "net/http/httptest" @@ -97,6 +98,99 @@ func TestReaderMediaProxyFailsClearlyWhenRefreshImpossible(t *testing.T) { } } +// TestReaderMediaProxyStopsResendingRejectedTicket 上游成批拒绝时(连新签的票 +// 也是空 401),不能在播放器那一次请求里把同一张被拒的票打上十几遍:连续两轮 +// 换票都是同一个状态码就立刻失败,把明确文案交出去。 +func TestReaderMediaProxyStopsResendingRejectedTicket(t *testing.T) { + svc, srv, book := prepareMediaTicketBook(t) + router := mediaRouterForTest(svc) + + signed := primeTicketBook(t, svc, book.ID) + srv.denyAlways() + + code, body := getReaderMedia(t, router, signed) + if code != http.StatusBadGateway { + t.Fatalf("上游持续拒绝应返回 502,实际 %d body=%s", code, body) + } + if !strings.Contains(body, "401") { + t.Fatalf("错误文案应带上上游状态码: %s", body) + } + // 每轮 fetchReaderMedia = 首拉(401) + 换票后重拉(401),两轮即收手。 + if got := srv.mediaRequests(); got > 4 { + t.Fatalf("无进展的拒绝应当两轮就收手,实际上游请求 %d 次", got) + } +} + +// TestReaderAudioCacheResumesTruncatedUpstream 上游中途 RST 后用 Range 续传: +// 播放器最终拿到的仍是完整的一章,而不是从头重下。 +func TestReaderAudioCacheResumesTruncatedUpstream(t *testing.T) { + const bodyBytes = 300 << 10 + payload := bytes.Repeat([]byte("m4a!"), bodyBytes/4) + var ranges []string + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + ranges = append(ranges, r.Header.Get("Range")) + count := len(ranges) + mu.Unlock() + + w.Header().Set("Content-Type", "audio/mp4") + w.Header().Set("Accept-Ranges", "bytes") + start := 0 + if count > 1 { + // 第二次起按 Range 续传。 + start = bodyBytes / 2 + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, bodyBytes-1, bodyBytes)) + w.WriteHeader(http.StatusPartialContent) + } else { + w.Header().Set("Content-Length", strconv.Itoa(bodyBytes)) + } + chunk := payload[start:] + if count == 1 { + // 第一次只写一半就断开。 + chunk = payload[:bodyBytes/2] + } + for written := 0; written < len(chunk); written += 8 << 10 { + end := written + 8<<10 + if end > len(chunk) { + end = len(chunk) + } + if _, err := w.Write(chunk[written:end]); err != nil { + return + } + } + w.(http.Flusher).Flush() + if count == 1 { + if hj, ok := w.(http.Hijacker); ok { + if conn, _, err := hj.Hijack(); err == nil { + _ = conn.Close() + } + } + } + })) + defer srv.Close() + + svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL) + router := mediaRouterForTest(svc) + signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media") + + code, body := getReaderMedia(t, router, signed) + if code != http.StatusOK { + t.Fatalf("续传后应当成功,实际 status=%d body=%.120s", code, body) + } + if len(body) != bodyBytes { + t.Fatalf("交付字节 = %d,期望 %d", len(body), bodyBytes) + } + mu.Lock() + defer mu.Unlock() + if len(ranges) < 2 || ranges[0] != "" { + t.Fatalf("请求序列 = %q,期望先整段再续传", ranges) + } + if !strings.HasPrefix(ranges[1], "bytes=") { + t.Fatalf("第二轮应当带 Range,实际 %q", ranges[1]) + } +} + // TestReaderMediaProxyPassesThroughNonTrackError 漫画图片这类不可换票的地址, // 上游 4xx 仍按原状态透传(既有行为不变)。 func TestReaderMediaProxyPassesThroughNonTrackError(t *testing.T) { @@ -347,12 +441,13 @@ func primeTicketBook(t *testing.T, svc *service.Container, bookID string) string // - /img/* 图片资源,固定 403(模拟防盗链) type mediaTicketServer struct { *httptest.Server - mu sync.Mutex - issued []string - hits int - mediaHits int - noToken bool - errorPage bool + mu sync.Mutex + issued []string + hits int + mediaHits int + noToken bool + errorPage bool + alwaysDeny bool } func newMediaTicketServer(t *testing.T) *mediaTicketServer { @@ -379,7 +474,13 @@ func newMediaTicketServer(t *testing.T) *mediaTicketServer { case r.URL.Path == "/media": s.mu.Lock() s.mediaHits++ + deny := s.alwaysDeny s.mu.Unlock() + if deny { + // 与腾讯 CDN 一致:连新签的票也直接空 401(成批拒绝的那几分钟)。 + w.WriteHeader(http.StatusUnauthorized) + return + } if s.errorPageNow() { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`
ticket expired`)) @@ -435,6 +536,13 @@ func (s *mediaTicketServer) serveErrorPage() { s.mu.Unlock() } +// denyAlways 让媒体端点连新签的票也一律空 401(模拟上游成批拒绝的那几分钟)。 +func (s *mediaTicketServer) denyAlways() { + s.mu.Lock() + s.alwaysDeny = true + s.mu.Unlock() +} + func (s *mediaTicketServer) errorPageNow() bool { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index 32fab4e..d5800c9 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -993,12 +993,8 @@ func streamReaderMediaBody(w io.Writer, body io.Reader) { const ( // readerAudioCacheAttempts 首次落盘的最多尝试次数(每次内部都会走「失败换票」)。 + // 续传只补没下完的部分,所以这里的次数比过去更可用:每次失败都能推进进度。 readerAudioCacheAttempts = 4 - // readerAudioCacheRetryDelay 重试退避基数,实际等待为 (attempt-1) 倍。 - readerAudioCacheRetryDelay = 500 * time.Millisecond - // readerAudioCacheMaxBytes 单个音频的落盘上限。有声书一章约 3–5MB, - // 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。 - readerAudioCacheMaxBytes = 64 << 20 // readerMediaReissueAttempts 换票重试次数:跨境 CDN 的「空 401」是概率性的, // 一张票不行往往换一张就好;旧实现只换一次,第二次失败就把 502 交给播放器。 readerMediaReissueAttempts = 3 @@ -1082,8 +1078,9 @@ func ensureCachedRemoteAudio( return path, nil } -// downloadReaderAudioToCache 整段拉取并落盘:上游断流/换票失败时重试, -// 落盘后按 Content-Length 校验长度,防止把截断的音频缓存下来。 +// downloadReaderAudioToCache 整段拉取并落盘,支持断点续传: +// 上游 RST/超时后用 Range 从已写入的位置接着下,不再整段重下; +// 只有字节数与上游声明的总长一致时才会生成正式缓存文件。 func downloadReaderAudioToCache( ctx context.Context, svc *service.Container, @@ -1091,76 +1088,30 @@ func downloadReaderAudioToCache( bookID, rawURL string, headers map[string]string, ) error { - var lastErr error - for attempt := 1; attempt <= readerAudioCacheAttempts; attempt++ { - if attempt > 1 { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(readerAudioCacheRetryDelay * time.Duration(attempt-1)): - } - } - out, err := fetchReaderMediaResilient(ctx, svc, book, rawURL, "", headers, true) - if err != nil { - lastErr = err - continue - } - if out == nil || out.resp == nil { - lastErr = fmt.Errorf("音源响应为空") - continue - } - resp := out.resp - status := resp.StatusCode - if status != http.StatusOK && status != http.StatusPartialContent { - _ = resp.Body.Close() - lastErr = fmt.Errorf("音源返回 %d", status) - continue - } - expected := resp.ContentLength - path, storeErr := svc.Reader.StoreCachedRemoteAudio(bookID, rawURL, func(w io.Writer) error { - if len(out.head) > 0 { - if _, err := w.Write(out.head); err != nil { - return err + _, err := svc.Reader.DownloadRemoteAudio(ctx, bookID, rawURL, readerAudioCacheAttempts, + func(rangeHeader string) (*http.Response, []byte, error) { + // 只有整段请求才需要嗅探响应体(判 HLS/错误页);续传拿到的是 206 + // 二进制分片,读了还得再补写一次,没必要。 + out, fetchErr := fetchReaderMediaResilient(ctx, svc, book, rawURL, rangeHeader, headers, rangeHeader == "") + if fetchErr != nil { + if isReaderMediaNoRetry(fetchErr) { + return nil, nil, &reader.NoRetryError{Err: fetchErr} } + return nil, nil, fetchErr } - return copyLimited(w, resp.Body, readerAudioCacheMaxBytes) + return out.resp, out.head, nil }) - _ = resp.Body.Close() - if storeErr != nil { - lastErr = storeErr - continue - } - if expected > 0 { - info, statErr := os.Stat(path) - if statErr != nil { - _ = os.Remove(path) - lastErr = fmt.Errorf("音频缓存写入后不可读: %w", statErr) - continue - } - if info.Size() != expected { - _ = os.Remove(path) - lastErr = fmt.Errorf("音频写入不完整(%d/%d 字节)", info.Size(), expected) - continue - } - } - return nil - } - if lastErr == nil { - lastErr = fmt.Errorf("音源拉取失败") - } - return fmt.Errorf("已重试 %d 次: %w", readerAudioCacheAttempts, lastErr) + return err } -// copyLimited 复制响应体,超过上限时报错(避免把超大对象写进缓存)。 -func copyLimited(w io.Writer, r io.Reader, limit int64) error { - n, err := io.Copy(w, io.LimitReader(r, limit+1)) - if err != nil { - return err +// isReaderMediaNoRetry 判断这次失败是否「换个花样重试也没用」。 +// 用于让外层下载循环立刻收手,而不是把同一张被拒的票再打四遍。 +func isReaderMediaNoRetry(err error) bool { + if errors.Is(err, errReaderMediaStaleTicket) { + return true } - if n > limit { - return fmt.Errorf("音源超过 %d 字节上限", limit) - } - return nil + var rejected *readerMediaRejectedError + return errors.As(err, &rejected) } // readerAudioExt 取音频地址的后缀(用于推断 Content-Type)。 @@ -1230,6 +1181,33 @@ type readerMediaResult struct { // 不改变原有行为; // - 该换票但换不到、或换票后依然不可用 → 返回错误,由调用方给出明确文案, // 而不是让播放器对着必然失败的地址反复重试。 +// +// readerMediaRejectedError 上游拒绝了这张票,继续走同一条重试路径不会有进展。 +// +// status —— 上游状态码(401/403/410) +// refreshed —— 这次拒绝发生在「已经换过一张新票」之后 +// message —— 对外文案(留空时按状态码生成) +// +// 上游的拒绝常常成批出现(几分钟一波,随后自己恢复):换票拿到的是新票,但 +// 同一时刻仍被同一个状态码挡回。连续两轮都这样时立刻停止重试——继续重试只会 +// 让播放器多等十几秒,结果并不会更好。 +type readerMediaRejectedError struct { + status int + refreshed bool + message string +} + +func (e *readerMediaRejectedError) Error() string { + if e.message != "" { + return e.message + } + return fmt.Sprintf("音源暂时被上游拒绝(HTTP %d),请稍后重试或换源", e.status) +} + +// errReaderMediaStaleTicket 换票没有拿到新票(书源还是给回同一张、或干脆给不出), +// 再重试同一个书源不会有不同结果。具体原因由外层用 %w 串上。 +var errReaderMediaStaleTicket = errors.New("音源签名失效且重新取票失败") + func fetchReaderMedia( ctx context.Context, svc *service.Container, @@ -1257,7 +1235,10 @@ func fetchReaderMedia( case out.resp.StatusCode == http.StatusUnauthorized: _, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16)) _ = out.resp.Body.Close() - return nil, fmt.Errorf("音源签名失效(上游 401 拒绝),请稍后重试或换源") + return nil, &readerMediaRejectedError{ + status: http.StatusUnauthorized, + message: "音源签名失效(上游 401 拒绝),请稍后重试或换源", + } } return out, nil } @@ -1265,7 +1246,7 @@ func fetchReaderMedia( _, _ = io.Copy(io.Discard, io.LimitReader(out.resp.Body, 1<<16)) _ = out.resp.Body.Close() if refreshErr != nil { - return nil, fmt.Errorf("音源签名失效且重新取票失败: %w", refreshErr) + return nil, fmt.Errorf("%w: %v", errReaderMediaStaleTicket, refreshErr) } retried, _, retryErr := fetchReaderMediaOnce(ctx, svc, book, fresh, rangeHeader, options, probeBody) if retryErr != nil { @@ -1273,10 +1254,16 @@ func fetchReaderMedia( } if retried.rejected { _ = retried.resp.Body.Close() - return nil, fmt.Errorf("音源换票后仍返回非媒体内容,请稍后重试或换源") + return nil, &readerMediaRejectedError{ + status: retried.resp.StatusCode, + refreshed: true, + message: "音源换票后仍返回非媒体内容,请稍后重试或换源", + } } if reader.MediaNeedsRefresh(retried.resp.StatusCode) { - return nil, fmt.Errorf("音源换票后仍被拒绝(上游 %d),请稍后重试或换源", retried.resp.StatusCode) + status := retried.resp.StatusCode + _ = retried.resp.Body.Close() + return nil, &readerMediaRejectedError{status: status, refreshed: true} } return retried, nil } @@ -1285,8 +1272,11 @@ func fetchReaderMedia( // // 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次, // 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮, -// 每轮都会作废章节缓存、重新回源签票。不可恢复的 4xx(如 404)原样返回, -// 不做无意义的等待。 +// 每轮都会作废章节缓存、重新回源签票。 +// +// 但"没有进展"的失败会提前收手,否则用户要对着转圈干等三十多秒: +// - 换票拿不到新票(errReaderMediaStaleTicket)→ 立即结束; +// - 换过票仍是同一个拒绝状态码 → 两轮即结束(见 readerMediaRejectedError)。 func fetchReaderMediaResilient( ctx context.Context, svc *service.Container, @@ -1296,6 +1286,7 @@ func fetchReaderMediaResilient( probeBody bool, ) (*readerMediaResult, error) { var lastErr error + lastRejected := 0 for attempt := 1; attempt <= readerMediaReissueAttempts; attempt++ { if attempt > 1 { select { @@ -1313,13 +1304,29 @@ func fetchReaderMediaResilient( if !out.rejected && !reader.MediaNeedsRefresh(out.resp.StatusCode) { return out, nil } - lastErr = fmt.Errorf("音源换票后仍不可用(上游 %d)", out.resp.StatusCode) + status := out.resp.StatusCode _ = out.resp.Body.Close() - continue + lastErr = &readerMediaRejectedError{status: status, refreshed: true} + } else { + lastErr = err + if ctx.Err() != nil { + return nil, err + } + if errors.Is(err, errReaderMediaStaleTicket) { + return nil, err + } } - lastErr = err - if ctx.Err() != nil { - return nil, err + var rejected *readerMediaRejectedError + if errors.As(lastErr, &rejected) { + // 没有换票空间的拒绝:原样重试同一张票没有意义。 + if !rejected.refreshed { + return nil, lastErr + } + // 换过票还是同一个状态码:上游这几分钟就是不放行。 + if lastRejected == rejected.status { + return nil, lastErr + } + lastRejected = rejected.status } } return nil, lastErr diff --git a/internal/service/cache_cleanup.go b/internal/service/cache_cleanup.go index 8769e14..e2cfa75 100644 --- a/internal/service/cache_cleanup.go +++ b/internal/service/cache_cleanup.go @@ -364,3 +364,197 @@ func ImageCachePools(imagesDir string, originalsMaxBytes int64) []ImageCachePool } 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 不在其中:它由自身 1GB 配额在写入时独立淘汰。 +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 由自身 1GB 配额在写入时 +// 淘汰,正文缓存和书源文件缓存另有自己的 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 +} diff --git a/internal/service/cache_cleanup_test.go b/internal/service/cache_cleanup_test.go index bcc5b2d..51bf607 100644 --- a/internal/service/cache_cleanup_test.go +++ b/internal/service/cache_cleanup_test.go @@ -134,7 +134,7 @@ func TestPruneImageCache_ZeroOrNegativeLimit(t *testing.T) { } } -func TestSchedulerJobCleanImageCache(t *testing.T) { +func TestSchedulerJobCleanCache(t *testing.T) { cacheRoot := t.TempDir() imagesDir := filepath.Join(cacheRoot, "images") if err := os.MkdirAll(imagesDir, 0o750); err != nil { @@ -148,15 +148,117 @@ 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.SetImageCachePolicyProvider(func() ImageCachePolicy { - return ImageCachePolicy{TotalBytes: 1 * 1024 * 1024} + scheduler.SetCacheCleanupPolicyProvider(func() CacheCleanupPolicy { + return CacheCleanupPolicy{TotalBytes: 1 * 1024 * 1024} }) - if err := scheduler.jobCleanImageCache(context.Background()); err != nil { - t.Fatalf("jobCleanImageCache failed: %v", err) + if err := scheduler.jobCleanCache(context.Background()); err != nil { + t.Fatalf("jobCleanCache failed: %v", err) } if _, err := os.Stat(f); !os.IsNotExist(err) { t.Fatalf("expected file to be pruned, got err=%v", err) } } + +// TestPruneCacheEvictionOrder 验证总量超限时严格按 +// hls → images → library-covers 的顺序淘汰,且 reader-audio 不参与。 +func TestPruneCacheEvictionOrder(t *testing.T) { + cacheRoot := t.TempDir() + now := time.Now() + + audio := filepath.Join(cacheRoot, "reader-audio", "old.mp3") + hls := filepath.Join(cacheRoot, "hls", "job", "seg.ts") + image := filepath.Join(cacheRoot, "images", "originals", "poster.jpg") + cover := filepath.Join(cacheRoot, "library-covers", "cover.jpg") + for _, p := range []string{audio, hls, image, cover} { + writePoolFile(t, p, 1024*1024, now.Add(-time.Hour)) + } + + // 计入总量的只有 hls+images+cover = 3MB,上限 2MB -> 回落到 1.6MB: + // 按顺序删 hls、images 两个文件即可;reader-audio 与封面都应保留。 + res, err := PruneCache(cacheRoot, 2*1024*1024, 0) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + if res.DeletedFiles != 2 { + t.Fatalf("deleted files = %d, want 2", res.DeletedFiles) + } + mustExist(t, audio) + mustNotExist(t, hls) + mustNotExist(t, image) + mustExist(t, cover) + if _, ok := res.FreedByPool["reader-audio"]; ok { + t.Fatalf("reader-audio must not be part of the global pool, got %+v", res.FreedByPool) + } + if res.FreedByPool["hls"] != 1024*1024 || res.FreedByPool["images"] != 1024*1024 { + t.Fatalf("unexpected per-pool stats: %+v", res.FreedByPool) + } +} + +// TestPruneCacheLeavesReaderAudioAlone reader-audio 有独立的 1GB 写入时淘汰, +// 不参与总容量统计:即使它单独就超过全局上限,全局任务也不应删它。 +func TestPruneCacheLeavesReaderAudioAlone(t *testing.T) { + cacheRoot := t.TempDir() + audio := filepath.Join(cacheRoot, "reader-audio", "book", "chapter.mp3") + writePoolFile(t, audio, 4*1024*1024, time.Now().Add(-time.Hour)) + + res, err := PruneCache(cacheRoot, 1*1024*1024, 0) + if err != nil { + t.Fatalf("prune failed: %v", err) + } + if res.DeletedFiles != 0 { + t.Fatalf("deleted files = %d, want 0", res.DeletedFiles) + } + if res.TotalBytesBefore != 0 { + t.Fatalf("reader-audio should not count toward total, got %d bytes", res.TotalBytesBefore) + } + mustExist(t, audio) +} + +// TestPruneCacheAppliesOriginalsSubQuota 验证总量没超时,图片原图子配额仍然生效。 +func TestPruneCacheAppliesOriginalsSubQuota(t *testing.T) { + cacheRoot := t.TempDir() + now := time.Now() + + oldOriginal := filepath.Join(cacheRoot, "images", "originals", "old.jpg") + newOriginal := filepath.Join(cacheRoot, "images", "originals", "new.jpg") + derived := currentVariantPath(filepath.Join(cacheRoot, "images", "resize"), "d", 480, 600, 80) + writePoolFile(t, oldOriginal, 800, now.Add(-2*time.Hour)) + writePoolFile(t, newOriginal, 800, now.Add(-time.Hour)) + writePoolFile(t, derived, 800, now.Add(-time.Hour)) + + // 总量上限很大(不触发全局淘汰),原图子配额 1000B -> 删到 800B。 + res, err := PruneCache(cacheRoot, 100*1024*1024, 1000) + 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, derived) +} + +// TestPruneCacheOldestFirstWithinPool 验证同一池内先删最旧的文件。 +func TestPruneCacheOldestFirstWithinPool(t *testing.T) { + cacheRoot := t.TempDir() + now := time.Now() + + oldHLS := filepath.Join(cacheRoot, "hls", "job-a", "seg1.ts") + newHLS := filepath.Join(cacheRoot, "hls", "job-b", "seg1.ts") + writePoolFile(t, oldHLS, 1024*1024, now.Add(-2*time.Hour)) + writePoolFile(t, newHLS, 1024*1024, now.Add(-time.Minute)) + + // 上限 1.25MB -> 回落水位 1MB:删掉最旧的 1MB 后正好达标。 + res, err := PruneCache(cacheRoot, 1280*1024, 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, oldHLS) + mustExist(t, newHLS) +} diff --git a/internal/service/image_proxy.go b/internal/service/image_proxy.go index c457957..128dc7d 100644 --- a/internal/service/image_proxy.go +++ b/internal/service/image_proxy.go @@ -199,17 +199,19 @@ func imageResizeConcurrency() int { return n } -// Prune removes oldest cached images until disk usage is within the configured limit. -// 原图池按独立配额优先淘汰,客户端热路径读取的成品图最后淘汰。 +// Prune 只执行图片原图子配额与旧缓存清理;整个磁盘缓存的总量淘汰由调度器的 +// cache_cleanup 任务(PruneCache)负责。 func (p *ImageProxy) Prune() (PruneImageCacheResult, error) { - if p.cfg == nil || p.cfg.Cache.ImagesMaxSizeMB <= 0 { + if p.cfg == nil { return PruneImageCacheResult{}, nil } var originalsMaxBytes int64 if p.cfg.Cache.ImagesOriginalsMaxSizeMB > 0 { originalsMaxBytes = int64(p.cfg.Cache.ImagesOriginalsMaxSizeMB) * 1024 * 1024 } - totalBytes := int64(p.cfg.Cache.ImagesMaxSizeMB) * 1024 * 1024 + if originalsMaxBytes <= 0 { + return PruneImageCacheResult{}, nil + } pools := ImageCachePools(p.cacheDir, originalsMaxBytes) - return PruneImageCachePools(pools, totalBytes) + return PruneImageCachePools(pools, 0) } diff --git a/internal/service/reader/audio_media_cache.go b/internal/service/reader/audio_media_cache.go index 4e1b578..7c2110b 100644 --- a/internal/service/reader/audio_media_cache.go +++ b/internal/service/reader/audio_media_cache.go @@ -1,15 +1,18 @@ package reader import ( + "context" "crypto/sha256" "encoding/hex" "errors" "fmt" "io" + "net/http" "net/url" "os" "path/filepath" "sort" + "strconv" "strings" "sync" "time" @@ -29,6 +32,18 @@ const ( audioMediaMaxBytes = 2 << 30 // 2GiB:超出后按 mtime 淘汰最旧的 audioMediaPrunePeriod = 10 * time.Minute audioMediaPruneKeepPct = 90 + + // audioPartialSuffix 未下完的音轨分片后缀。 + // + // 分片跨请求保留:首播失败后用户再点一次、或播放器重连,都能用 Range 接着下, + // 不必从第 0 字节重来。分片名带 .part 后缀,不会被 OpenCachedRemoteAudio 当成 + // 完整音轨下发。 + audioPartialSuffix = ".part" + // audioMediaObjectMaxBytes 单个音频对象的落盘上限。有声书一章约 3–5MB, + // 给到 64MB 足够容纳长章节,也能挡住误当成音频的超大对象。 + audioMediaObjectMaxBytes = 64 << 20 + // audioDownloadRetryDelay 断点续传重试的退避基数,实际等待为 (attempt-1) 倍。 + audioDownloadRetryDelay = 500 * time.Millisecond ) var ( @@ -132,41 +147,253 @@ func (s *ReaderService) OpenCachedRemoteAudio(bookID, mediaURL string) (*os.File return f, info, true } -// StoreCachedRemoteAudio 把一段音源原子写入缓存,fill 负责把内容写进 writer。 -// 返回最终文件路径;内容为空或写入失败时不留下半成品。 -func (s *ReaderService) StoreCachedRemoteAudio(bookID, mediaURL string, fill func(io.Writer) error) (string, error) { +// errAudioObjectTooLarge 上游对象超过单章缓存上限(不是可重试的错误)。 +var errAudioObjectTooLarge = errors.New("音源超过缓存上限") + +// NoRetryError 标记「换着花样再试也不会有进展」的失败(例如上游正成批拒绝 +// 签名票、书源根本给不出新票)。带断点续传的下载器收到它会立刻停止,并把 +// Err 原样交给调用方——继续重试只会让播放器多等十几秒,还会让对方更坚定地 +// 拒绝我们。 +type NoRetryError struct{ Err error } + +func (e *NoRetryError) Error() string { return e.Err.Error() } +func (e *NoRetryError) Unwrap() error { return e.Err } + +// AudioRangeFetcher 取一次上游音轨响应。 +// +// rangeHeader 为空表示整段请求;否则形如 "bytes=N-",用于从断点续传。 +// head 是调用方已经嗅探读过、必须补写回文件的前缀(只有整段请求才有)。 +// 调用方负责在内部完成换票;这里只看状态码和字节。 +type AudioRangeFetcher func(rangeHeader string) (resp *http.Response, head []byte, err error) + +// DownloadRemoteAudio 带断点续传地把整段音轨写进缓存,返回最终文件路径。 +// +// 为什么需要续传:腾讯 CDN 的跨境边缘常在传输中途 RST,旧实现每次重试都从 +// 第 0 字节重下,一章 3MB 白下几次就把首播拖到几十秒。这里把已写入的字节留在 +//{audioError ? ( - - {audioError} - + // 失败态下这行就是重试入口:服务端这一轮要十几秒才判定失败, + // 与其让用户对着转圈,不如把「为什么」和「再试一次」一起给他。 + ) : ( <> {loading ? (transcoding ? '服务端转码中,请稍候…' : '加载中…') : playing ? '播放中' : '已暂停'} · diff --git a/web/src/pages/reader/readerAudioEngine.ts b/web/src/pages/reader/readerAudioEngine.ts index adcf1cf..53d6967 100644 --- a/web/src/pages/reader/readerAudioEngine.ts +++ b/web/src/pages/reader/readerAudioEngine.ts @@ -22,8 +22,15 @@ import { readerCoverSrc } from '../../utils/readerCover' const PROGRESS_SAVE_INTERVAL_MS = 5_000 /** -15s / +15s 步长(对齐面板与 legado SEEK_STEP)。 */ const SEEK_STEP = 15 -/** 音源加载失败后自动重试(重新换取签名地址)的次数上限。 */ -const AUDIO_RETRY_LIMIT = 2 +/** + * 音源加载失败后自动重试的退避间隔(毫秒),数组长度即自动重试次数。 + * + * 别调得太短:服务端媒体代理要回源换票、拉整章音频,失败一次本身就要十几秒, + * 立刻重试只会让用户对着转圈更久。第一次 3 秒、第二次 9 秒,总等待可控。 + */ +const AUDIO_RETRY_DELAYS_MS = [3_000, 9_000] +/** 自动重试次数上限(与退避表的长度一致)。 */ +const AUDIO_RETRY_LIMIT = AUDIO_RETRY_DELAYS_MS.length export interface LoadChapterInput { bookId: string @@ -183,10 +190,23 @@ class ReaderAudioEngine { } toggle(): void { + // 失败态下播放键就是「重试」:重新解析本章音轨(换一张签名票)再装源, + // 而不是对着一份已经出错的 src 再调 play()。 + if (useReaderAudioStore.getState().status === 'error') { + this.retry() + return + } if (this.audio.paused) this.play() else this.pause() } + /** 用户手动重试:清掉自动重试计数与待执行定时器,立刻重新解析本章音轨。 */ + retry(): void { + this.resetRetry() + useReaderAudioStore.setState({ status: 'loading', error: '' }) + void this.reloadCurrentChapter() + } + seekBy(delta: number): void { const total = Number.isFinite(this.audio.duration) ? this.audio.duration @@ -482,13 +502,23 @@ class ReaderAudioEngine { } } - /** 退避后再重新解析本章音轨;期间保持 loading 态,不给用户闪错误。 */ + /** + * 退避后再重新解析本章音轨。 + * + * 期间刻意停留在 error 态而不是 loading:服务端这次请求本来就要十几秒才判定 + * 失败,再挂一个转圈只会让人以为卡死。明确告诉他「几秒后自动重试」,并且这 + * 行提示本身可以点,等于把等待权交回用户。 + */ private scheduleRetry(): void { if (this.retryTimer !== null) window.clearTimeout(this.retryTimer) - useReaderAudioStore.setState({ status: 'loading', error: '' }) - const delay = 800 * this.retryCount + const delay = AUDIO_RETRY_DELAYS_MS[Math.min(this.retryCount - 1, AUDIO_RETRY_LIMIT - 1)] + useReaderAudioStore.setState({ + status: 'error', + error: `音源暂时取不到,${Math.round(delay / 1000)} 秒后自动重试`, + }) this.retryTimer = window.setTimeout(() => { this.retryTimer = null + useReaderAudioStore.setState({ status: 'loading', error: '' }) void this.reloadCurrentChapter() }, delay) } @@ -615,7 +645,7 @@ class ReaderAudioEngine { audio.addEventListener('error', () => { const code = audio.error?.code - // 服务端代理拉的是腾讯 CDN 的直链,跨境链路会偶发 502 / 中途断流。 + // 服务端代理拉的是第三方 CDN 的直链,跨境链路会偶发被上游拒绝 / 中途断流。 // 浏览器对这两种情况都报 SRC_NOT_SUPPORTED(看起来像「格式不支持」), // 实际上重新取一次章节(换一张签名票)通常就能播,所以先自动重试。 const retryable = @@ -625,11 +655,12 @@ class ReaderAudioEngine { this.scheduleRetry() return } + // 重试用完:文案要能解释「为什么播不了」,并且面板上这行本身就是重试按钮。 this.fail( code === MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED - ? '音频加载失败:服务端多次重试后仍取不到音源,请稍后再试' + ? '音源暂时被上游拒绝' : code === MediaError.MEDIA_ERR_NETWORK - ? '音频加载失败,请检查网络后重试' + ? '网络异常,音源没取到' : '音频无法播放', ) }) diff --git a/web/src/pages/settingsGroupGeneral.ts b/web/src/pages/settingsGroupGeneral.ts index 738ea93..a5cc810 100644 --- a/web/src/pages/settingsGroupGeneral.ts +++ b/web/src/pages/settingsGroupGeneral.ts @@ -129,17 +129,17 @@ export const generalSettingsGroup: SettingGroup = { }, { key: 'cache.images_max_size_mb', - label: '图片缓存上限 (MB)', + label: '磁盘缓存总容量上限 (MB)', type: 'number', - hint: '海报、剧照等图片代理缓存目录 (cache/images) 的总占用上限。超过上限时先淘汰原图、再按修改时间清理最旧的缩略图;上限过小会导致旧图被淘汰后又要重新从上游/挂载的 Emby 下载。设为 0 表示不限制,默认 2000MB', - defaultValue: '2000', - placeholder: '2000', + hint: '磁盘缓存(hls、images、library-covers)的总占用上限。超过上限时严格按「转码 HLS → 图片 → 媒体库封面」的顺序淘汰最旧的文件,直到回落到上限的 80%。有声书转码缓存(reader-audio)另有 1GB 写入时淘汰,不计入这里。设为 0 表示不限制,默认 2048MB(2GB)', + defaultValue: '2048', + placeholder: '2048', }, { key: 'cache.images_originals_max_size_mb', - label: '原图缓存上限 (MB)', + label: '图片原图缓存上限 (MB)', type: 'number', - hint: '原图只是生成缩略图的原料,丢了可以重新回源,因此单独给一个小配额(默认 256MB)。总量超限时原图也优先被淘汰,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制', + hint: 'originals 子目录的独立配额(默认 256MB)。原图只是生成缩略图的原料,丢了可以重新回源,因此单独给小配额;图片池被淘汰时优先删原图,保证客户端实际读取的缩略图能留住。设为 0 表示不单独限制', defaultValue: '256', placeholder: '256', },