diff --git a/internal/handler/reader_media_test.go b/internal/handler/reader_media_test.go index 8fa8fc6..5c12d24 100644 --- a/internal/handler/reader_media_test.go +++ b/internal/handler/reader_media_test.go @@ -189,11 +189,15 @@ func TestReaderMediaProbeRetriesConnectionReset(t *testing.T) { } } -// TestReaderMediaAbortsWhenUpstreamTruncates 上游中途断开且重试都失败时, -// 代理必须让客户端看到「传输不完整」,不能返回一个长度正确却内容缺失的响应。 +// TestReaderMediaAbortsWhenUpstreamTruncates 非音轨的流式透传路径:上游中途 +// 断开且重试都失败时,必须让客户端看到「传输不完整」,不能返回一个长度正确 +// 却内容缺失的响应。 // // 以前这里是 `_, _ = io.Copy(...)`,错误被丢掉,客户端拿到一个正常结束的 // 半截文件 —— 播放器只能报「格式不支持」(3003),根因被彻底掩盖。 +// +// 音轨走的是整段落盘缓存(见 TestReaderAudioCacheRejectsTruncatedUpstream): +// 拉不完整就重试,全部失败回 502,绝不会把半截音频交给播放器。 func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) { const declared = 400 << 10 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -217,7 +221,8 @@ func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) { svc, _, book := prepareMediaTicketBookWithUpstream(t, srv.URL) router := mediaRouterForTest(svc) - signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media") + // .mp4 不是音频后缀:走流式透传分支(音轨会被整段落盘缓存)。 + signed := svc.Reader.ProxyURL(book.ID, srv.URL+"/media.mp4") w := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, signed, nil) @@ -239,6 +244,71 @@ func TestReaderMediaAbortsWhenUpstreamTruncates(t *testing.T) { } } +// TestReaderAudioCacheServesFromDisk 音轨首次拉取后落盘;后续请求即使带着一张 +// 新签的票(query 全变了)也不再回源——这是让播放不受跨境链路抖动影响的关键。 +func TestReaderAudioCacheServesFromDisk(t *testing.T) { + svc, srv, book := prepareMediaTicketBook(t) + router := mediaRouterForTest(svc) + signed := primeTicketBook(t, svc, book.ID) + + code, body := getReaderMedia(t, router, signed) + if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") { + t.Fatalf("首次拉取失败 status=%d body=%q", code, body) + } + if got := srv.mediaRequests(); got != 1 { + t.Fatalf("首次拉取应对上游请求 1 次,实际 %d", got) + } + + // 同一段音频的另一张票:URL 的 query 不同,缓存键必须一致。 + rotated := svc.Reader.ProxyURL(book.ID, srv.URL+"/media?t=ticket-rotated") + code, body = getReaderMedia(t, router, rotated) + if code != http.StatusOK || !strings.Contains(body, "fake-audio-bytes") { + t.Fatalf("命中缓存失败 status=%d body=%q", code, body) + } + if got := srv.mediaRequests(); got != 1 { + t.Fatalf("命中缓存后不该再回源,实际上游请求 %d 次", got) + } +} + +// TestReaderAudioCacheRejectsTruncatedUpstream 音轨拉不完整时重试;全部失败回 502, +// 且不能留下半截缓存文件(否则后续会把截断的音频当完整文件下发)。 +func TestReaderAudioCacheRejectsTruncatedUpstream(t *testing.T) { + const declared = 400 << 10 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "audio/mp4") + w.Header().Set("Content-Length", strconv.Itoa(declared)) + payload := make([]byte, 8<<10) + for written := 0; written < 128<<10; written += len(payload) { + if _, err := w.Write(payload); err != nil { + return + } + } + w.(http.Flusher).Flush() + 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.StatusBadGateway { + t.Fatalf("上游持续截断应当返回 502,实际 %d body=%.120s", code, body) + } + if strings.Contains(body, "ftyp") { + t.Fatalf("不能把半截音频交给客户端: %q", body) + } + if _, _, ok := svc.Reader.OpenCachedRemoteAudio(book.ID, srv.URL+"/media"); ok { + t.Fatalf("失败的下载不应留下缓存文件") + } +} + func mediaRouterForTest(svc *service.Container) *gin.Engine { gin.SetMode(gin.TestMode) r := gin.New() @@ -280,6 +350,7 @@ type mediaTicketServer struct { mu sync.Mutex issued []string hits int + mediaHits int noToken bool errorPage bool } @@ -306,6 +377,9 @@ func newMediaTicketServer(t *testing.T) *mediaTicketServer { token := s.newTokenLocked(fmt.Sprintf("ticket-%d", n)) _, _ = w.Write([]byte(fmt.Sprintf(`{"data":{"url":"%s/media?t=%s"}}`, s.URL, token))) case r.URL.Path == "/media": + s.mu.Lock() + s.mediaHits++ + s.mu.Unlock() if s.errorPageNow() { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(`ticket expired`)) @@ -373,6 +447,12 @@ func (s *mediaTicketServer) contentHits() int { return s.hits } +func (s *mediaTicketServer) mediaRequests() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.mediaHits +} + // prepareMediaTicketBookWithUpstream 用指定的上游地址建一本书(上游由调用方提供)。 func prepareMediaTicketBookWithUpstream(t *testing.T, upstream string) (*service.Container, *mediaTicketServer, *model.ReaderBook) { t.Helper() diff --git a/internal/handler/reader_routes.go b/internal/handler/reader_routes.go index f0a794b..32fab4e 100644 --- a/internal/handler/reader_routes.go +++ b/internal/handler/reader_routes.go @@ -8,12 +8,16 @@ import ( "fmt" "io" "net/http" + "net/url" "os" + "path" "strconv" "strings" "time" "github.com/gin-gonic/gin" + "go.uber.org/zap" + "golang.org/x/sync/singleflight" "github.com/truewhile/MeBox/internal/middleware" "github.com/truewhile/MeBox/internal/model" @@ -878,6 +882,22 @@ func readerMediaProxyHandler(svc *service.Container) gin.HandlerFunc { // 图片地址可自带 ",{headers:{...}}" 选项段:拆分后 headers 逐图应用, // 代理地址里只保留不带选项的地址(见 ProxyURL)。 rawURL, mediaOptions, _ := readerrule.ParseMediaOptions(rawURL) + + // 远端音轨:整段落盘缓存,命中后不再回源。 + // + // 听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回空 401 + // 或直接 RST 连接。旧实现每次都把抖动原样交给播放器:要么是 502 的 + // JSON,要么是截断的 206,浏览器一律判成 MEDIA_ERR_SRC_NOT_SUPPORTED + // (前端据此提示「格式不支持,请检查 ffmpeg」——其实完全无关)。 + // 缓存后只要首播成功一次,后续拖动进度、续播、重试都走本地文件。 + // 缓存目录不可用时退回旧的流式透传,不让配置问题直接变成「播不了」。 + if c.Request.Method == http.MethodGet && + reader.IsAudioMediaURL(rawURL, book.Type) && + svc.Reader.RemoteAudioCachePath(bookID, rawURL) != "" { + handleCachedReaderAudio(c, svc, book, bookID, rawURL, mediaOptions.Headers) + return + } + // 只有 200 的 GET 需要嗅探响应体(判播放列表 / 判错误页); // 206 的体是二进制分片、HEAD 没有体,读了反而要额外拼回去。 probeBody := c.Request.Method == http.MethodGet @@ -969,6 +989,209 @@ 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 + readerMediaReissueDelay = 300 * time.Millisecond +) + +// readerAudioFetchGroup 同一音轨并发请求(播放器 + 预取 + 预热)只下载一次。 +var readerAudioFetchGroup singleflight.Group + +// handleCachedReaderAudio 音频直链的统一入口:先查本地缓存,未命中则整段拉取落盘, +// 最后交给 http.ServeContent 处理 Range(拖动进度、续播都读本地文件)。 +func handleCachedReaderAudio( + c *gin.Context, + svc *service.Container, + book *model.ReaderBook, + bookID, rawURL string, + headers map[string]string, +) { + if f, info, ok := svc.Reader.OpenCachedRemoteAudio(bookID, rawURL); ok { + defer f.Close() + serveCachedReaderAudio(c, f, info, rawURL) + return + } + cachePath, err := ensureCachedRemoteAudio(c.Request.Context(), svc, book, bookID, rawURL, headers) + if err != nil { + if svc.Log != nil { + svc.Log.Warn("reader 音频拉取失败", zap.String("book", bookID), zap.Error(err)) + } + c.String(http.StatusBadGateway, "音频拉取失败: %s", err.Error()) + return + } + f, err := os.Open(cachePath) // #nosec G304 -- 路径由服务端哈希生成 + if err != nil { + c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error()) + return + } + defer f.Close() + info, err := f.Stat() + if err != nil { + c.String(http.StatusBadGateway, "音频缓存不可用: %s", err.Error()) + return + } + serveCachedReaderAudio(c, f, info, rawURL) +} + +// serveCachedReaderAudio 用 http.ServeContent 下发本地音轨(自带 Range / 206 语义)。 +func serveCachedReaderAudio(c *gin.Context, f *os.File, info os.FileInfo, rawURL string) { + c.Header("Content-Type", readerAudioContentType(rawURL)) + c.Header("Cache-Control", "private, max-age=604800") + http.ServeContent(c.Writer, c.Request, "audio"+readerAudioExt(rawURL), info.ModTime(), f) +} + +// ensureCachedRemoteAudio 单飞下载:并发命中同一章时只回源一次。 +func ensureCachedRemoteAudio( + ctx context.Context, + svc *service.Container, + book *model.ReaderBook, + bookID, rawURL string, + headers map[string]string, +) (string, error) { + key := svc.Reader.RemoteAudioCachePath(bookID, rawURL) + if key == "" { + return "", fmt.Errorf("音频缓存目录不可用") + } + v, err, _ := readerAudioFetchGroup.Do(key, func() (any, error) { + if svc.Reader.HasCachedRemoteAudio(bookID, rawURL) { + return key, nil + } + if err := downloadReaderAudioToCache(ctx, svc, book, bookID, rawURL, headers); err != nil { + return nil, err + } + return key, nil + }) + if err != nil { + return "", err + } + path, _ := v.(string) + if path == "" { + return "", fmt.Errorf("音频缓存不可用") + } + return path, nil +} + +// downloadReaderAudioToCache 整段拉取并落盘:上游断流/换票失败时重试, +// 落盘后按 Content-Length 校验长度,防止把截断的音频缓存下来。 +func downloadReaderAudioToCache( + ctx context.Context, + svc *service.Container, + book *model.ReaderBook, + 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 + } + } + return copyLimited(w, resp.Body, readerAudioCacheMaxBytes) + }) + _ = 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) +} + +// 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 + } + if n > limit { + return fmt.Errorf("音源超过 %d 字节上限", limit) + } + return nil +} + +// readerAudioExt 取音频地址的后缀(用于推断 Content-Type)。 +func readerAudioExt(rawURL string) string { + u, err := url.Parse(rawURL) + if err != nil { + return "" + } + return strings.ToLower(path.Ext(u.Path)) +} + +// readerAudioContentType 按后缀给出 Content-Type:缓存文件统一存成 .audio, +// 不能依赖 http.ServeContent 的嗅探,直接在响应头里写死。 +func readerAudioContentType(rawURL string) string { + switch readerAudioExt(rawURL) { + case ".mp3": + return "audio/mpeg" + case ".aac": + return "audio/aac" + case ".flac": + return "audio/flac" + case ".wav": + return "audio/wav" + case ".ogg", ".oga", ".opus": + return "audio/ogg" + default: + // .m4a / .m4b / .wma / .ape 以及无后缀地址:腾讯的听书直链都是 m4a。 + return "audio/mp4" + } +} + // readerImageDecodeMaxBytes 需要服务端解密的单张图片上限。 const readerImageDecodeMaxBytes = 32 << 20 @@ -1034,7 +1257,7 @@ 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, fmt.Errorf("音源签名失效(上游 401 拒绝),请稍后重试或换源") } return out, nil } @@ -1058,6 +1281,50 @@ func fetchReaderMedia( return retried, nil } +// fetchReaderMediaResilient 在 fetchReaderMedia(已含一次换票)之上再包一层。 +// +// 跨境 CDN 判掉签名票是概率性的:换一张票往往就能取到,而旧实现只换一次, +// 第二次失败直接把 502 交给播放器。这里最多再重来 readerMediaReissueAttempts 轮, +// 每轮都会作废章节缓存、重新回源签票。不可恢复的 4xx(如 404)原样返回, +// 不做无意义的等待。 +func fetchReaderMediaResilient( + ctx context.Context, + svc *service.Container, + book *model.ReaderBook, + rawURL, rangeHeader string, + options map[string]string, + probeBody bool, +) (*readerMediaResult, error) { + var lastErr error + for attempt := 1; attempt <= readerMediaReissueAttempts; attempt++ { + if attempt > 1 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(readerMediaReissueDelay * time.Duration(attempt-1)): + } + } + out, err := fetchReaderMedia(ctx, svc, book, rawURL, rangeHeader, options, probeBody) + if err == nil { + if out == nil || out.resp == nil { + lastErr = fmt.Errorf("音源响应为空") + continue + } + if !out.rejected && !reader.MediaNeedsRefresh(out.resp.StatusCode) { + return out, nil + } + lastErr = fmt.Errorf("音源换票后仍不可用(上游 %d)", out.resp.StatusCode) + _ = out.resp.Body.Close() + continue + } + lastErr = err + if ctx.Err() != nil { + return nil, err + } + } + return nil, lastErr +} + // readerMediaProbeAttempts 探测阶段遇到连接中断(reset/EOF)的最大尝试次数。 // // 上游 CDN(腾讯 CDN 的跨境边缘)在并发或长传输时会直接 RST 连接,同一章 diff --git a/internal/service/reader/audio_media_cache.go b/internal/service/reader/audio_media_cache.go new file mode 100644 index 0000000..4e1b578 --- /dev/null +++ b/internal/service/reader/audio_media_cache.go @@ -0,0 +1,222 @@ +package reader + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// 远端音轨的本地落盘缓存(媒体代理 /api/reader/media 使用)。 +// +// 背景:听书源的音频直链签在腾讯音乐 CDN 上,跨境链路会间歇性返回 401 +// 或直接 RST 连接。旧行为是每次请求(首播、拖动进度、播放器重试)都重新 +// 回源,一次抖动就把错误交给播放器。这里按「书 + 媒体对象路径」把整段音频 +// 落盘(忽略 ?vkey=... 这类会轮换的签名参数),命中后由 http.ServeContent +// 按 Range 下发:首播成功之后不再依赖 CDN,拖动进度也是本地读取。 + +const ( + audioMediaDirName = "reader-audio" + audioMediaTTL = 7 * 24 * time.Hour + audioMediaMaxBytes = 2 << 30 // 2GiB:超出后按 mtime 淘汰最旧的 + audioMediaPrunePeriod = 10 * time.Minute + audioMediaPruneKeepPct = 90 +) + +var ( + audioMediaPruneMu sync.Mutex + audioMediaPrunedAt time.Time +) + +// audioMediaDir 音频缓存根目录(与转码缓存同一套目录约定)。 +func (s *ReaderService) audioMediaDir() string { + if s == nil || s.cfg == nil { + return "" + } + base := strings.TrimSpace(s.cfg.Cache.CacheDir) + if base == "" { + if dataDir := strings.TrimSpace(s.cfg.App.DataDir); dataDir != "" { + base = filepath.Join(dataDir, "cache") + } + } + if base == "" { + return "" + } + return filepath.Join(base, audioMediaDirName) +} + +// audioMediaCacheKey 缓存键:书 + scheme://host/path。 +// +// 必须忽略 query:同一段音频每次换票都会得到新的 vkey/uid/ct,只按完整 URL +// 取哈希的话缓存永远打不中。 +func audioMediaCacheKey(bookID, mediaURL string) string { + u, err := url.Parse(strings.TrimSpace(mediaURL)) + if err != nil || u.Host == "" { + return "" + } + ident := strings.ToLower(u.Scheme) + "://" + strings.ToLower(u.Host) + u.EscapedPath() + sum := sha256.Sum256([]byte(bookID + "|" + ident)) + return hex.EncodeToString(sum[:16]) +} + +// RemoteAudioCachePath 返回该音轨的缓存文件路径;无法缓存时返回空串。 +func (s *ReaderService) RemoteAudioCachePath(bookID, mediaURL string) string { + key := audioMediaCacheKey(bookID, mediaURL) + if key == "" { + return "" + } + dir := s.audioMediaDir() + if dir == "" { + return "" + } + return filepath.Join(dir, key[:2], key+".audio") +} + +// IsAudioMediaURL 判断媒体代理这次要拉的是不是音轨。 +// +// 后缀优先:音频书籍里也会出现封面/插图(.jpg)或 HLS 播放列表(.m3u8), +// 这些不能当成音轨整段落盘。只有后缀为空(签名直链常见形态)时才退回按 +// 书籍类型判断。 +func IsAudioMediaURL(rawURL string, bookType int) bool { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return bookType == 1 + } + ext := strings.ToLower(filepath.Ext(u.Path)) + if ext == "" { + return bookType == 1 + } + return audioExtensions[ext] +} + +// HasCachedRemoteAudio 判断该音轨是否已有可用缓存(会顺带刷新 mtime)。 +func (s *ReaderService) HasCachedRemoteAudio(bookID, mediaURL string) bool { + f, _, ok := s.OpenCachedRemoteAudio(bookID, mediaURL) + if ok { + _ = f.Close() + } + return ok +} + +// OpenCachedRemoteAudio 打开已缓存的音轨(命中返回打开的文件,调用方负责关闭)。 +// 过期的缓存顺手删掉,未过期则刷新 mtime 供 LRU 淘汰。 +func (s *ReaderService) OpenCachedRemoteAudio(bookID, mediaURL string) (*os.File, os.FileInfo, bool) { + path := s.RemoteAudioCachePath(bookID, mediaURL) + if path == "" { + return nil, nil, false + } + f, err := os.Open(path) // #nosec G304 -- 路径由服务端哈希生成 + if err != nil { + return nil, nil, false + } + info, err := f.Stat() + if err != nil || info.IsDir() || info.Size() == 0 { + _ = f.Close() + return nil, nil, false + } + if info.ModTime().Add(audioMediaTTL).Before(time.Now()) { + _ = f.Close() + _ = os.Remove(path) + return nil, nil, false + } + now := time.Now() + _ = os.Chtimes(path, now, now) + return f, info, true +} + +// StoreCachedRemoteAudio 把一段音源原子写入缓存,fill 负责把内容写进 writer。 +// 返回最终文件路径;内容为空或写入失败时不留下半成品。 +func (s *ReaderService) StoreCachedRemoteAudio(bookID, mediaURL string, fill func(io.Writer) error) (string, error) { + path := s.RemoteAudioCachePath(bookID, mediaURL) + if path == "" { + return "", errors.New("音频缓存目录未配置") + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return "", fmt.Errorf("创建音频缓存目录失败: %w", err) + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".audio-*") + if err != nil { + return "", fmt.Errorf("创建音频缓存文件失败: %w", err) + } + tmpName := tmp.Name() + fillErr := fill(tmp) + closeErr := tmp.Close() + if fillErr != nil || closeErr != nil { + _ = os.Remove(tmpName) + if fillErr != nil { + return "", fillErr + } + return "", closeErr + } + info, statErr := os.Stat(tmpName) + if statErr != nil || info.Size() == 0 { + _ = os.Remove(tmpName) + return "", errors.New("音源内容为空") + } + if err := os.Rename(tmpName, path); err != nil { + _ = os.Remove(tmpName) + return "", fmt.Errorf("写入音频缓存失败: %w", err) + } + s.pruneAudioMediaCache() + return path, nil +} + +// pruneAudioMediaCache 低频清理:删过期文件,超容量时按 mtime 淘汰最旧的。 +func (s *ReaderService) pruneAudioMediaCache() { + dir := s.audioMediaDir() + if dir == "" { + return + } + audioMediaPruneMu.Lock() + if time.Since(audioMediaPrunedAt) < audioMediaPrunePeriod { + audioMediaPruneMu.Unlock() + return + } + audioMediaPrunedAt = time.Now() + audioMediaPruneMu.Unlock() + + type entry struct { + path string + size int64 + mod time.Time + } + var entries []entry + var total int64 + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + info, infoErr := d.Info() + if infoErr != nil { + return nil + } + if info.ModTime().Add(audioMediaTTL).Before(time.Now()) { + _ = os.Remove(path) + return nil + } + entries = append(entries, entry{path: path, size: info.Size(), mod: info.ModTime()}) + total += info.Size() + return nil + }) + if total <= audioMediaMaxBytes { + return + } + sort.Slice(entries, func(i, j int) bool { return entries[i].mod.Before(entries[j].mod) }) + target := int64(audioMediaMaxBytes) * audioMediaPruneKeepPct / 100 + for _, e := range entries { + if total <= target { + break + } + if err := os.Remove(e.path); err == nil { + total -= e.size + } + } +} diff --git a/internal/service/reader/audio_media_cache_test.go b/internal/service/reader/audio_media_cache_test.go new file mode 100644 index 0000000..a420a9f --- /dev/null +++ b/internal/service/reader/audio_media_cache_test.go @@ -0,0 +1,41 @@ +package reader + +import "testing" + +func TestIsAudioMediaURL(t *testing.T) { + cases := []struct { + name string + url string + bookType int + want bool + }{ + {"m4a 直链", "https://cdn.example.com/a.m4a?uid=1&vkey=x", 0, true}, + {"mp3 直链", "https://cdn.example.com/a.MP3", 0, true}, + {"音频书里的插图", "https://cdn.example.com/img/cover.jpg", 1, false}, + {"HLS 播放列表", "https://cdn.example.com/index.m3u8?token=1", 1, false}, + {"无后缀签名直链(音频书)", "https://cdn.example.com/stream?token=1", 1, true}, + {"无后缀签名直链(文本/图片书)", "https://cdn.example.com/stream?token=1", 0, false}, + {"坏地址但书籍是音频", "://bad", 1, true}, + } + for _, c := range cases { + if got := IsAudioMediaURL(c.url, c.bookType); got != c.want { + t.Errorf("%s: IsAudioMediaURL(%q, %d) = %v,期望 %v", c.name, c.url, c.bookType, got, c.want) + } + } +} + +// TestAudioMediaCacheKeyIgnoresQuery 换票会改写 uid/vkey/ct,缓存键必须只看 +// scheme + host + path,否则每次换票都会重新下载一遍。 +func TestAudioMediaCacheKeyIgnoresQuery(t *testing.T) { + a := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=1&vkey=aaa") + b := audioMediaCacheKey("book-1", "https://cdn.example.com/long/a.m4a?uid=2&vkey=bbb&ct=3") + if a == "" || a != b { + t.Fatalf("同一对象换票后缓存键应一致: %q vs %q", a, b) + } + if c := audioMediaCacheKey("book-2", "https://cdn.example.com/long/a.m4a"); c == a { + t.Fatalf("不同书籍的缓存键不应相同") + } + if d := audioMediaCacheKey("book-1", "https://cdn.example.com/long/b.m4a"); d == a { + t.Fatalf("不同对象的缓存键不应相同") + } +} diff --git a/web/src/pages/reader/readerAudioEngine.ts b/web/src/pages/reader/readerAudioEngine.ts index e077bd1..adcf1cf 100644 --- a/web/src/pages/reader/readerAudioEngine.ts +++ b/web/src/pages/reader/readerAudioEngine.ts @@ -22,6 +22,8 @@ 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 export interface LoadChapterInput { bookId: string @@ -78,6 +80,10 @@ class ReaderAudioEngine { private warmer: HTMLAudioElement | null = null /** warmer 当前装载的音轨,避免重复赋值。 */ private warmedTrack = '' + /** 当前章已自动重试的次数(换章/加载成功时清零)。 */ + private retryCount = 0 + /** 自动重试的定时器句柄。 */ + private retryTimer: number | null = null constructor() { const audio = new Audio() @@ -107,6 +113,8 @@ class ReaderAudioEngine { } // 换章前把上一章的最后位置补报掉(此时 store 里还是上一章的上下文) this.commitProgress() + // 换章是一次全新的加载:清掉上一章遗留的自动重试计数/定时器 + this.resetRetry() // 换书:预取的音轨属于上一本,全部作废 if (s.bookId !== input.bookId) this.clearPrefetch() this.applyChapter(input) @@ -244,6 +252,7 @@ class ReaderAudioEngine { /** 完全停止并清空状态(迷你播放器的关闭按钮)。 */ stop(): void { this.commitProgress() + this.resetRetry() this.detachSource() this.clearPrefetch() this.timerDeadline = 0 @@ -457,10 +466,70 @@ class ReaderAudioEngine { } private fail(message: string): void { + this.resetRetry() useReaderAudioStore.setState({ status: 'error', error: message }) this.setSessionPlaybackState('paused') } + // ─── 失败自动重试 ───────────────────────────────────────────────────── + + /** 清掉自动重试计数与待执行的定时器(换章、停止、最终失败时调用)。 */ + private resetRetry(): void { + this.retryCount = 0 + if (this.retryTimer !== null) { + window.clearTimeout(this.retryTimer) + this.retryTimer = null + } + } + + /** 退避后再重新解析本章音轨;期间保持 loading 态,不给用户闪错误。 */ + private scheduleRetry(): void { + if (this.retryTimer !== null) window.clearTimeout(this.retryTimer) + useReaderAudioStore.setState({ status: 'loading', error: '' }) + const delay = 800 * this.retryCount + this.retryTimer = window.setTimeout(() => { + this.retryTimer = null + void this.reloadCurrentChapter() + }, delay) + } + + /** + * 重新取一次章节内容再装源。服务端媒体代理会在签名票失效/上游抖动时 + * 重新回源签票;重新取内容能拿到新的签名地址,比原样重试同一个 URL 有效。 + */ + private async reloadCurrentChapter(): Promise { + const s = useReaderAudioStore.getState() + if (!s.bookId) { + this.fail('音频加载失败,请稍后重试') + return + } + const token = this.loadToken + const resumeAt = this.lastTime > 0 ? this.lastTime : s.cur + try { + const ct = await readerAPI.bookContent(s.bookId, s.chapterIndex) + if (token !== this.loadToken) return + const track = ct.tracks?.[0] + if (ct.type !== 'audio' || !track) { + this.fail('本章没有可播放的音频') + return + } + this.applyChapter({ + bookId: s.bookId, + bookName: s.bookName, + cover: s.cover, + chapters: s.chapters, + chapterIndex: s.chapterIndex, + track, + transcoding: ct.transcoding ?? false, + openCredits: s.openCredits, + closeCredits: s.closeCredits, + initialPos: resumeAt, + }) + } catch { + this.fail('音频加载失败,请稍后重试') + } + } + // ───